Compare commits
3 Commits
main
...
imalison/f
| Author | SHA1 | Date | |
|---|---|---|---|
| b79f876114 | |||
| e60c1a4eb1 | |||
| a2d218580c |
@@ -428,21 +428,19 @@ class CameraSession(private val context: Context, private val cameraManager: Cam
|
||||
|
||||
// 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.
|
||||
// Map device rotation to the correct orientation for video recording.
|
||||
// Surface.ROTATION_90 = device rotated 90° CCW = phone top on LEFT = LANDSCAPE_LEFT
|
||||
// Surface.ROTATION_270 = device rotated 90° CW = phone top on RIGHT = LANDSCAPE_RIGHT
|
||||
// Map device rotation to the correct orientationHint for video recording:
|
||||
// - Counter-clockwise (ROTATION_90) → 270° hint
|
||||
// - Clockwise (ROTATION_270) → 90° hint
|
||||
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
val deviceRotation = windowManager.defaultDisplay.rotation
|
||||
val recordingOrientation = when (deviceRotation) {
|
||||
Surface.ROTATION_0 -> Orientation.PORTRAIT
|
||||
Surface.ROTATION_90 -> Orientation.LANDSCAPE_LEFT
|
||||
Surface.ROTATION_90 -> Orientation.LANDSCAPE_RIGHT
|
||||
Surface.ROTATION_180 -> Orientation.PORTRAIT_UPSIDE_DOWN
|
||||
Surface.ROTATION_270 -> Orientation.LANDSCAPE_RIGHT
|
||||
Surface.ROTATION_270 -> Orientation.LANDSCAPE_LEFT
|
||||
else -> Orientation.PORTRAIT
|
||||
}
|
||||
|
||||
Log.i(TAG, "startRecording: orientation=${recordingOrientation.toDegrees()}° (deviceRotation=$deviceRotation)")
|
||||
|
||||
val recording = RecordingSession(
|
||||
context,
|
||||
cameraId,
|
||||
@@ -450,7 +448,7 @@ class CameraSession(private val context: Context, private val cameraManager: Cam
|
||||
enableAudio,
|
||||
fps,
|
||||
videoOutput.enableHdr,
|
||||
recordingOrientation,
|
||||
orientation,
|
||||
options,
|
||||
filePath,
|
||||
callback,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,9 +39,8 @@ class FragmentedRecordingManager(
|
||||
segmentDurationSeconds: Int = DEFAULT_SEGMENT_DURATION_SECONDS
|
||||
): FragmentedRecordingManager {
|
||||
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
|
||||
val orientationDegrees = cameraOrientation.toDegrees()
|
||||
val cameraOrientationDegrees = cameraOrientation.toDegrees()
|
||||
val recordingOrientationDegrees = (options.orientation ?: Orientation.PORTRAIT).toDegrees()
|
||||
|
||||
// Swap dimensions based on camera orientation, same as ChunkedRecordingManager
|
||||
val (width, height) = if (cameraOrientation.isLandscape()) {
|
||||
@@ -50,7 +49,9 @@ class FragmentedRecordingManager(
|
||||
size.width to size.height
|
||||
}
|
||||
|
||||
Log.d(TAG, "Recording: ${width}x${height}, orientation=$orientationDegrees°")
|
||||
Log.d(TAG, "Input size: ${size.width}x${size.height}, encoder size: ${width}x${height}, " +
|
||||
"cameraOrientation: $cameraOrientation ($cameraOrientationDegrees°), " +
|
||||
"recordingOrientation: $recordingOrientationDegrees°")
|
||||
|
||||
val format = MediaFormat.createVideoFormat(mimeType, width, height)
|
||||
val codec = MediaCodec.createEncoderByType(mimeType)
|
||||
@@ -60,18 +61,16 @@ class FragmentedRecordingManager(
|
||||
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
|
||||
)
|
||||
|
||||
// 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
|
||||
val effectiveFps = fps ?: 30
|
||||
format.setInteger(MediaFormat.KEY_FRAME_RATE, effectiveFps)
|
||||
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, segmentDurationSeconds)
|
||||
format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
|
||||
|
||||
Log.d(TAG, "Video Format: $format, orientation: $recordingOrientationDegrees")
|
||||
|
||||
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
|
||||
// Create muxer with callbacks and orientation
|
||||
val muxer = HlsMuxer(
|
||||
outputDirectory = outputDirectory,
|
||||
callback = object : HlsMuxer.Callback {
|
||||
@@ -83,11 +82,12 @@ class FragmentedRecordingManager(
|
||||
callbacks.onVideoChunkReady(file, index, durationUs)
|
||||
}
|
||||
},
|
||||
orientationDegrees = orientationDegrees,
|
||||
fps = effectiveFps
|
||||
orientationDegrees = recordingOrientationDegrees
|
||||
)
|
||||
muxer.setSegmentDuration(segmentDurationSeconds * 1_000_000L)
|
||||
|
||||
Log.d(TAG, "Created HlsMuxer with orientation: $recordingOrientationDegrees degrees")
|
||||
|
||||
return FragmentedRecordingManager(codec, muxer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,7 @@ import java.nio.ByteBuffer
|
||||
class HlsMuxer(
|
||||
private val outputDirectory: File,
|
||||
private val callback: Callback,
|
||||
private val orientationDegrees: Int = 0,
|
||||
private val fps: Int = 30
|
||||
private val orientationDegrees: Int = 0
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "HlsMuxer"
|
||||
@@ -42,7 +41,7 @@ class HlsMuxer(
|
||||
|
||||
// Configuration
|
||||
private var targetSegmentDurationUs: Long = DEFAULT_SEGMENT_DURATION_US
|
||||
private var timescale: Int = 30000 // Default, updated in addTrack() to fps * 1000
|
||||
private var timescale: Int = 30000 // Default, updated from format
|
||||
|
||||
// State
|
||||
private var state = State.UNINITIALIZED
|
||||
@@ -55,14 +54,6 @@ class HlsMuxer(
|
||||
private var segmentStartTimeUs = -1L
|
||||
private var lastPresentationTimeUs = 0L
|
||||
|
||||
// Timestamp normalization - MediaCodec timestamps are device uptime, not starting from 0
|
||||
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 {
|
||||
UNINITIALIZED,
|
||||
INITIALIZED,
|
||||
@@ -78,29 +69,6 @@ class HlsMuxer(
|
||||
val isKeyFrame: Boolean
|
||||
)
|
||||
|
||||
// ==================== Timestamp Normalization ====================
|
||||
|
||||
/**
|
||||
* Normalizes a presentation timestamp to start from 0.
|
||||
* The first timestamp received becomes time 0, and all subsequent
|
||||
* 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 {
|
||||
if (firstPresentationTimeUs < 0) {
|
||||
firstPresentationTimeUs = rawPresentationTimeUs
|
||||
Log.d(TAG, "First timestamp captured: ${rawPresentationTimeUs}us (${rawPresentationTimeUs / 1_000_000.0}s), normalizing to 0")
|
||||
}
|
||||
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 ====================
|
||||
|
||||
/**
|
||||
@@ -233,11 +201,13 @@ class HlsMuxer(
|
||||
|
||||
trackFormat = format
|
||||
|
||||
// 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
|
||||
// Extract timescale from frame rate
|
||||
val fps = try {
|
||||
format.getInteger(MediaFormat.KEY_FRAME_RATE)
|
||||
} catch (e: Exception) {
|
||||
30
|
||||
}
|
||||
timescale = fps * 1000 // Use fps * 1000 for good precision
|
||||
|
||||
state = State.INITIALIZED
|
||||
|
||||
@@ -245,7 +215,7 @@ class HlsMuxer(
|
||||
val formatHeight = try { format.getInteger(MediaFormat.KEY_HEIGHT) } catch (e: Exception) { -1 }
|
||||
Log.d(TAG, "Added track: ${format.getString(MediaFormat.KEY_MIME)}, " +
|
||||
"encoder output: ${formatWidth}x${formatHeight}, " +
|
||||
"fps=$fps, timescale=$timescale, orientation=$orientationDegrees°")
|
||||
"timescale=$timescale, orientation=$orientationDegrees°")
|
||||
|
||||
return 0 // Single track, index 0
|
||||
}
|
||||
@@ -257,30 +227,16 @@ class HlsMuxer(
|
||||
check(state == State.INITIALIZED) { "Must call addTrack() before start()" }
|
||||
val format = trackFormat ?: throw IllegalStateException("No track format")
|
||||
|
||||
// Create output directory if needed, with proper error handling
|
||||
// Create output directory if needed
|
||||
if (!outputDirectory.exists()) {
|
||||
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}")
|
||||
outputDirectory.mkdirs()
|
||||
}
|
||||
|
||||
// Write init segment
|
||||
val initBytes = buildInitSegment(format)
|
||||
val initFile = File(outputDirectory, "init.mp4")
|
||||
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, "Frame rate metadata: timescale=$timescale, fps=$fps, " +
|
||||
"default_sample_duration=$defaultSampleDuration (ffprobe should calculate ${timescale}/${defaultSampleDuration}=${fps}fps)")
|
||||
|
||||
callback.onInitSegmentReady(initFile)
|
||||
|
||||
state = State.STARTED
|
||||
@@ -303,40 +259,13 @@ class HlsMuxer(
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
val presentationTimeUs = bufferInfo.presentationTimeUs
|
||||
|
||||
// Initialize segment start time
|
||||
if (segmentStartTimeUs < 0) {
|
||||
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)
|
||||
if (isKeyFrame && pendingSamples.isNotEmpty()) {
|
||||
val segmentDurationUs = presentationTimeUs - segmentStartTimeUs
|
||||
@@ -355,6 +284,12 @@ class HlsMuxer(
|
||||
// Convert Annex-B (start codes) to AVCC (length prefixes)
|
||||
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)
|
||||
val estimatedDurationUs = if (lastPresentationTimeUs > 0) {
|
||||
presentationTimeUs - lastPresentationTimeUs
|
||||
@@ -416,7 +351,6 @@ class HlsMuxer(
|
||||
val durationUs = (lastSample.presentationTimeUs - firstPts) + lastSample.durationUs
|
||||
|
||||
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")
|
||||
|
||||
callback.onMediaSegmentReady(segmentFile, segmentIndex, durationUs)
|
||||
@@ -715,7 +649,7 @@ class HlsMuxer(
|
||||
private fun buildStblBox(width: Int, height: Int, sps: ByteArray, pps: ByteArray): ByteArray {
|
||||
val content = ByteArrayOutputStream()
|
||||
content.write(buildStsdBox(width, height, sps, pps))
|
||||
content.write(buildSttsBox()) // Contains default timing for ffprobe frame rate detection
|
||||
content.write(buildEmptySttsBox())
|
||||
content.write(buildEmptyStscBox())
|
||||
content.write(buildEmptyStszBox())
|
||||
content.write(buildEmptyStcoBox())
|
||||
@@ -782,21 +716,11 @@ class HlsMuxer(
|
||||
return wrapBox("avcC", output.toByteArray())
|
||||
}
|
||||
|
||||
private fun buildSttsBox(): ByteArray {
|
||||
private fun buildEmptySttsBox(): ByteArray {
|
||||
val output = ByteArrayOutputStream()
|
||||
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(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
|
||||
|
||||
dos.writeInt(0) // entry count
|
||||
return wrapBox("stts", output.toByteArray())
|
||||
}
|
||||
|
||||
@@ -833,15 +757,10 @@ class HlsMuxer(
|
||||
val output = ByteArrayOutputStream()
|
||||
val dos = DataOutputStream(output)
|
||||
|
||||
// Calculate default sample duration so ffprobe can derive correct fps
|
||||
// fps = timescale / default_sample_duration
|
||||
// At 30fps with timescale=30000: duration=1000, ffprobe calculates 30000/1000=30
|
||||
val defaultSampleDuration = timescale / fps
|
||||
|
||||
dos.writeInt(0) // version & flags
|
||||
dos.writeInt(1) // track ID
|
||||
dos.writeInt(1) // default sample description index
|
||||
dos.writeInt(defaultSampleDuration) // default sample duration
|
||||
dos.writeInt(0) // default sample duration
|
||||
dos.writeInt(0) // default sample size
|
||||
dos.writeInt(0) // default sample flags
|
||||
|
||||
@@ -885,11 +804,9 @@ class HlsMuxer(
|
||||
): ByteArray {
|
||||
// Calculate sizes to determine data offset
|
||||
val mfhdBox = buildMfhdBox(sequenceNumber)
|
||||
// tfhd: 8 header + 4 version/flags + 4 track_id + 4 duration + 4 size + 4 flags = 28 bytes
|
||||
val tfhdSize = 8 + 20
|
||||
val tfhdSize = 8 + 8 // box header + content (version/flags + track_id)
|
||||
val tfdtSize = 8 + 12 // box header + version 1 content
|
||||
// trun: 8 header + 12 fixed + per-sample (size + flags only, no duration)
|
||||
val trunSize = 8 + 12 + (samples.size * 8)
|
||||
val trunSize = 8 + 12 + (samples.size * 12) // header + fixed + per-sample (no composition offset)
|
||||
val trafSize = 8 + tfhdSize + tfdtSize + trunSize
|
||||
val moofSize = 8 + mfhdBox.size + trafSize
|
||||
|
||||
@@ -924,21 +841,9 @@ class HlsMuxer(
|
||||
val output = ByteArrayOutputStream()
|
||||
val dos = DataOutputStream(output)
|
||||
|
||||
// Calculate default sample duration for this fragment
|
||||
// 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)
|
||||
// Flags: default-base-is-moof (0x020000)
|
||||
dos.writeInt(0x00020000)
|
||||
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())
|
||||
}
|
||||
@@ -961,17 +866,16 @@ class HlsMuxer(
|
||||
val output = ByteArrayOutputStream()
|
||||
val dos = DataOutputStream(output)
|
||||
|
||||
// Flags: data-offset + sample-size + sample-flags
|
||||
// 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
|
||||
// Flags: data-offset + sample-duration + sample-size + sample-flags
|
||||
val flags = 0x000001 or 0x000100 or 0x000200 or 0x000400
|
||||
dos.writeInt(flags)
|
||||
dos.writeInt(samples.size)
|
||||
dos.writeInt(dataOffset)
|
||||
|
||||
for (sample in samples) {
|
||||
// No duration - using default from tfhd
|
||||
// Convert duration to timescale units
|
||||
val durationInTimescale = ((sample.durationUs * timescale) / 1_000_000).toInt()
|
||||
dos.writeInt(durationInTimescale)
|
||||
dos.writeInt(sample.data.size)
|
||||
dos.writeInt(buildSampleFlags(sample.isKeyFrame))
|
||||
}
|
||||
|
||||
@@ -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,13 @@ 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:/+"), "/"))
|
||||
private val outputPath: File = File(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 +82,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()
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user