RecordingStats.numBytesRecorded differs from finalized MP4 sample sizes

68 views
Skip to first unread message

Rish Bhardwaj

unread,
May 18, 2026, 2:45:33 PM (6 days ago) May 18
to Android CameraX Discussion Group, Anton Ivanov
Hi CameraX team,

We’re seeing a mismatch between VideoRecordEvent.Finalize.recordingStats.numBytesRecorded and the media sample sizes in the finalized MP4 on some devices.

Setup:
  - CameraX camera-video:1.5.3
  - VideoCapture / Recorder
  - Seen on Fairphone 4 5G and Samsung A23 5G
  - Sample video attached

For the attached selfie video:
  - recordingStats.numBytesRecorded: 853217
  - Sum of finalized MP4 audio/video sample sizes: 852822
  - Delta: 395 bytes / 0.046%
  - Full MP4 file size: 1252874 bytes

We calculate the finalized MP4 media payload by summing the stsz sample sizes for vide and soun tracks. I also verified the same total with ffprobe packet sizes.

On most devices we see these values match exactly, so this looks device-specific rather than a general expectation mismatch.

Question
Our hunch is that Recorder may be counting an encoded audio buffer around stop/finalize, but that buffer does not end up in the finalized MP4 sample table. The delta here is 395 bytes, which looks plausible for a small AAC sample, but we cannot prove that from app code.

Is this a known issue or possible race/ordering problem around final audio samples during stop/finalize, where RecordingStats is updated before the sample is actually included in the finalized MP4? Can this be fixed in CameraX?

Thanks,
Rish
d6d6031d-adb6-e269-0080-018471babffd_87b7c416-5a2e-4c25-abd3-66d08dc21a10_SELFIE_VIDEO.mp4

Leo Huang

unread,
May 18, 2026, 10:41:03 PM (6 days ago) May 18
to Android CameraX Discussion Group, ri...@revolut.com, anton....@revolut.com

Hi,

Thanks for the detailed report and the sample video.

We have analyzed the CameraX Recorder codebase to trace how numBytesRecorded is tracked and finalized.

We confirmed that a CameraX-side race condition is highly unlikely:

  • Durable Tracking: numBytesRecorded is strictly accumulated after `mMuxer.writeSampleData(...)` returns successfully.

  • Robust Teardown: CameraX fully drains both encoders and ensures all data is passed to the muxer before `mMuxer.stop()` is called.

As far as CameraX is concerned, all counted bytes were successfully handed over to the system `MediaMuxer` API.

Since this only occurs on specific devices (Fairphone 4 and Samsung A23), this may point to a bug in the platform's system MediaMuxer on those devices.

During `MediaMuxer.stop()`, the system muxer is likely silently discarding the last audio frame, even though `writeSampleData` returned success. The 395-byte delta is extremely consistent with exactly one missing AAC audio frame.

Suggested Workaround: To bypass platform-specific bugs, CameraX 1.6.X (from 1.6.0-beta02) has migrated to Media3's MediaMuxerCompat. This uses a robust, pure-Java muxer, ensuring consistent behavior across all devices. 

Hope this helps clarify the behavior!


ri...@revolut.com 在 2026年5月19日 星期二凌晨2:45:33 [UTC+8] 的信中寫道:

Rish Bhardwaj

unread,
May 19, 2026, 6:54:03 AM (6 days ago) May 19
to Leo Huang, Android CameraX Discussion Group, anton....@revolut.com
Thanks for your response Leo!
We tried again after updating CameraX to 1.6.1, but we see the mismatch there as well.

In our app we are not doing any extra accounting on top of CameraX. We read the value directly from VideoRecordEvent.Finalize:

```
  is VideoRecordEvent.Finalize -> {
      val encodedByteCount = event.recordingStats.numBytesRecorded
      ...
  }
```

Is there anything else you could recommend to us? Or would you like us to provide any logs? 

Best,
Rish

CONFIDENTIAL

This  e-mail  and  any  attachments  are  confidential  and  intended  solely  for  the  addressee  and  may  also  be privileged or exempt from disclosure under applicable law. If you are not the addressee, or have received this e-mail in error, please notify the sender immediately, delete it from your system and do not copy, disclose or otherwise act upon any part of this e-mail or its attachments. Internet communications are not guaranteed to be secure or virus-free. Revolut does not accept responsibility for any loss arising from unauthorised access to, or interference with, any Internet communications by any third party, or from the transmission of any viruses. Replies to this e-mail may be monitored by Revolut for operational or business reasons. Any  opinion  or  other  information  in  this  e-mail  or  its  attachments  that  does  not  relate  to  the  business  of Revolut is personal to the sender and is not given or endorsed by Revolut. Registered  Office: 30 South Colonnade, London E14 5HX, United Kingdom. Main Office: 30 South Colonnade, London E14 5HX, United  Kingdom. Revolut  Ltd  is  authorised  and regulated by the Financial Conduct Authority under the Electronic Money Regulations 2011, Firm Reference 900562.

Leo Huang

unread,
May 20, 2026, 3:02:17 AM (5 days ago) May 20
to Android CameraX Discussion Group, ri...@revolut.com, Android CameraX Discussion Group, anton....@revolut.com, Leo Huang

Hi,

After a thorough investigation, I have a suggestion how to handle it.

In CameraX, `recordingStats.numBytesRecorded` represents the sum of all raw, encoded audio and video frame sizes before they are handed over to the muxer. However, once CameraX passes these frames to the muxer, the muxer may perform containerization steps that alter the final physical byte count. Because these operations are handled privately inside the muxer, CameraX cannot know the exact, byte-level layout of the final MP4 file during recording. If your application requires a byte-exact payload size, you should not rely on `numBytesRecorded` as a source of truth, as it is designed to be an approximation. Instead, it is recommended querying the physical file once you receive the VideoRecordEvent.Finalize event. Since the muxer is fully closed and flushed at this point, the file on disk represents the absolute source of truth.

ri...@revolut.com 在 2026年5月19日 星期二下午6:54:03 [UTC+8] 的信中寫道:

Leo Huang

unread,
May 20, 2026, 5:10:07 AM (5 days ago) May 20
to Android CameraX Discussion Group, Leo Huang, ri...@revolut.com, Android CameraX Discussion Group, anton....@revolut.com
By the way, you should be able to use the MediaExtractor to calculate the payload once VideoRecordEvent.Finalize is received.

Sample:

    import android.media.MediaExtractor
    import android.media.MediaFormat
    import java.io.IOException
    import java.nio.ByteBuffer

    /**
     * Calculates the precise media payload size (sum of all audio and video frames)                                                          
     * inside an MP4 file using MediaExtractor.                                                                                                
     *
     * @param filePath The absolute path to the MP4 file on the device.                                                                        
     * @return The total payload size in bytes.                                                                                                
     */
    @Throws(IOException::class)
    fun getMp4PayloadSize(filePath: String): Long {
        val extractor = MediaExtractor()

        try {
            // 1. Set the file path as the data source                                                                                        
            extractor.setDataSource(filePath)

            val trackCount = extractor.trackCount
            val trackSizes = LongArray(trackCount)
            val trackSampleCounts = IntArray(trackCount)

            // 2. Programmatically select all tracks (video, audio, metadata, etc.)                                                            
            for (i in 0 until trackCount) {
                val format = extractor.getTrackFormat(i)
                val mime = format.getString(MediaFormat.KEY_MIME) ?: "unknown"

                extractor.selectTrack(i)

                println("Track $i select: Mime Type = $mime")
            }

            // 3. Allocate a buffer large enough to hold a single video frame (e.g., 1 MB)                                                    
            val buffer = ByteBuffer.allocate(1024 * 1024)
            var totalPayloadSize = 0L

            // 4. Loop sequentially through all interleaved samples in the file                                                                
            while (true) {
                val trackIndex = extractor.sampleTrackIndex

                // If trackIndex is less than 0, we have reached the End of File (EOF)                                                        
                if (trackIndex < 0) {
                    break
                }

                // Read the current sample's binary data into the buffer.                                                                      
                // This method returns the exact size in bytes of this single compressed frame.                                                
                val sampleSize = extractor.readSampleData(buffer, 0)
                if (sampleSize < 0) {
                    break // Safety EOF check                                                                                                  
                }

                // Accumulate the size for this specific track and the total payload size                                                      
                trackSizes[trackIndex] += sampleSize
                trackSampleCounts[trackIndex]++
                totalPayloadSize += sampleSize

                // 5. Advance the extractor to the next sample in the MP4 container
                extractor.advance()
            }

            // Optional: Log details for debugging
            for (i in 0 until trackCount) {
                val format = extractor.getTrackFormat(i)
                val mime = format.getString(MediaFormat.KEY_MIME) ?: "unknown"
                println("Track $i [$mime]: Size = ${trackSizes[i]} bytes, Total Samples = ${trackSampleCounts[i]}")
            }
            println("Calculated total MP4 payload size: $totalPayloadSize bytes")

            return totalPayloadSize

        } finally {
            // 6. ALWAYS release the extractor to free native resources!
            extractor.release()
        }
    }

Leo Huang 在 2026年5月20日 星期三下午3:02:17 [UTC+8] 的信中寫道:
Reply all
Reply to author
Forward
0 new messages