Search code examples
javavideomp4frame-rateandroid-mediacodec

Video length is different from starting and ending time stamp in MediaCodec


I have a legacy code written by someone else and I just recently realised that the video recorded in the app has different length when it is played outside the app or programmatically checked using MediaMetadataRetriever.METADATA_KEY_DURATION than the measured time using the frames' timeStamps.

The difference is almost 20% longer video than measured.

Below is the printout from the frame timestamps:

frameRate: 30                                
estimateStarttime: 83990376186060            
estimateEndTime: 84009908074060 

Which translates to 19.53 seconds while results from the MediaPlayer and MediaMetadataRetriever.METADATA_KEY_DURATION

duration player for Example-video is 23520
duration flag for Example-video is 23520

And here is a snippet of my code:

final ArrayList<Surface> als = new ArrayList<>();
        final Surface is;

        Range<Integer> targetFps = null;
        Range<Integer>[] ranges = targetDetails.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES);
        if(ranges != null)
            for (Range<Integer> range : ranges) {
                //Request the highest available lower==upper fps range.
                Log.d("FPSRangeMeta", String.format(Locale.getDefault(), "(%dx%d): [%d..%d]", 1280, 720, range.getLower(), range.getUpper()));
                if(range.getUpper().equals(range.getLower())){
                        if(targetFps == null)
                            targetFps = range;
                        if(range.getLower() > targetFps.getLower())
                            targetFps = range;
                    }
            }


        //noinspection ConstantConditions targetFps to be implemented
        final int framerate =  targetFps != null?targetFps.getLower():15;
        System.out.println("frameRate: " + framerate);
        if (recording) {
            MediaCodec.Callback cb = new MediaCodec.Callback() {

                @Override
                public void onInputBufferAvailable(@NonNull MediaCodec mc, int inputBufferId) {
                    throw new RuntimeException("Shouldn't be called with an input surface.");
                }


                @SuppressLint("LogConditional")
                @Override
                public void onOutputBufferAvailable(@NonNull MediaCodec codec, int index, @NonNull MediaCodec.BufferInfo info) {
                    try {
                        ByteBuffer outputBuffer = Objects.requireNonNull(codec.getOutputBuffer(index));
                        //codec.getOutputFormat(index); // option A

                        synchronized (CameraHandler.this) {
                            long flags = info.flags;
                            if(estimateTimestamp == 0 && estimateStarttime != 0) {
                                estimateTimestamp = estimateStarttime;
                                System.out.println("estimateStarttime: " + estimateStarttime);
                            }
                            encodedFrameCallback.pushFrameMeta("frame.keyframe", frameIndex, flags & MediaCodec.BUFFER_FLAG_KEY_FRAME);
                            encodedFrameCallback.pushFrameMeta("frame.output_time_info", frameIndex, info.presentationTimeUs*1000);
                            encodedFrameCallback.pushFrameMeta("frame.frame_delta", frameIndex, info.presentationTimeUs*1000 - prev);
                            if(prev != 0){
                                estimateTimestamp += info.presentationTimeUs*1000 - prev;
                            }
                            encodedFrameCallback.pushFrameMeta("frame.estimated_timestamp", frameIndex, estimateTimestamp);
                            encodedFrameCallback.pushH264Frame(outputBuffer, info.offset, info.size, info.presentationTimeUs);
                            prev = info.presentationTimeUs*1000;
                            frameIndex++;
                        }
                        codec.releaseOutputBuffer(index, false);
                    } catch (IllegalStateException e) {
                        Timber.e(e);
                    }
                }

                @Override
                public void onError(@NonNull MediaCodec codec, @NonNull MediaCodec.CodecException e) {
                    Log.d("MCError", "MediaCodec error");
                    e.printStackTrace();
                }

                @Override
                public void onOutputFormatChanged(@NonNull MediaCodec mc, @NonNull MediaFormat format) {
                }
            };

            //noinspection HardCodedStringLiteral
            MediaFormat mf = MediaFormat.createVideoFormat("video/avc", 1280, 720);

            Range<Integer> complexityRange = codec.getCodecInfo().getCapabilitiesForType("video/avc").getEncoderCapabilities().getComplexityRange();
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
                Range<Integer> qualityRange = codec.getCodecInfo().getCapabilitiesForType("video/avc").getEncoderCapabilities().getQualityRange();
                Log.d("CameraHandler", "Available quality range: "+qualityRange.getLower()+" --- "+qualityRange.getUpper());
                if(!qualityRange.getLower().equals(qualityRange.getUpper())) mf.setInteger(MediaFormat.KEY_QUALITY, qualityRange.getUpper());
            } else  {
                Log.d("CameraHandler", "Can't set quality, Android version too low");
            }

            MediaCodecInfo.EncoderCapabilities ec = codec.getCodecInfo().getCapabilitiesForType("video/avc").getEncoderCapabilities();
            Log.d("CameraHandler", "CBR:" + ec.isBitrateModeSupported(MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR));
            Log.d("CameraHandler", "CQ:" + ec.isBitrateModeSupported(MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CQ));
            Log.d("CameraHandler", "VBR:" + ec.isBitrateModeSupported(MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR));

            Log.d("CameraHandler", "Available complexity range: "+complexityRange.getLower()+" --- "+complexityRange.getUpper());


            mf.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
            mf.setInteger(MediaFormat.KEY_BIT_RATE, 12140318);//Same as sample file from Xperia Z5 recording.
            mf.setInteger(MediaFormat.KEY_FRAME_RATE, framerate);
            mf.setInteger(MediaFormat.KEY_CAPTURE_RATE, framerate);
            mf.setInteger(MediaFormat.KEY_PRIORITY, 0);
            mf.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 5);
            mf.setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, 100000000);


            mf.setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileHigh);
            mf.setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel41);
            try {
                codec.reset();
                codec.setCallback(cb);
                codec.configure(mf, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
            } catch(Exception failed_hi41){

                mf.setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileHigh);
                mf.setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31);
                failed_hi41.printStackTrace();
                try {
                    codec.reset();
                    codec.setCallback(cb);
                    codec.configure(mf, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
                } catch(Exception failed_hi31){
                    mf.setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileMain);
                    mf.setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31);
                    codec.reset();
                    codec.setCallback(cb);
                    codec.configure(mf, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
                }

            }

            is = codec.createInputSurface();

            als.add(is);
        }

        boolean finalUseMotionTrack = useMotionTrack;
        Range<Integer> finalTargetFps = targetFps;
        CameraDevice.StateCallback cameraCallback = new CameraDevice.StateCallback() {
            @Override
            public void onOpened(@NonNull final CameraDevice camera) {
                currentCamera = camera;

                if(closed) return;

                try {
                    if (dicb != null)
                        dicb.deviceInfo(
                            codec.getOutputFormat(),
                            camera,
                            framerate,
                            cameraManager.getCameraCharacteristics(camera.getId()));

                } catch (CameraAccessException e) {
                    e.printStackTrace();
                }

                try {

                    final CaptureRequest.Builder cr = camera.createCaptureRequest(CameraDevice.TEMPLATE_RECORD);

                    //Use control mode AUTO so we can access A3 features.
                    cr.set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_AUTO);

                    //Use standard video-oriented auto focus - since some devices can't live without it...
                    cr.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_VIDEO);

                    //Use standard white balance optimization
                    cr.set(CaptureRequest.CONTROL_AWB_MODE, CaptureRequest.CONTROL_AWB_MODE_AUTO);

                    //Enable auto exposure. This is not ideal, but manual configuration is hard over multiple devices.
                    cr.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON);
                    if(finalTargetFps != null) cr.set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, finalTargetFps);

                    //Request widest available field of view
                    float focalLengths[] = targetDetails.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS);
                    if(focalLengths != null && focalLengths.length > 0)
                        cr.set(CaptureRequest.LENS_FOCAL_LENGTH, focalLengths[0]);

                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && finalUseMotionTrack) {
                        cr.set(CaptureRequest.CONTROL_CAPTURE_INTENT, CaptureRequest.CONTROL_CAPTURE_INTENT_MOTION_TRACKING);
                    } else {
                        cr.set(CaptureRequest.CONTROL_CAPTURE_INTENT, CaptureRequest.CONTROL_CAPTURE_INTENT_VIDEO_RECORD);
                    }

                    cr.set(CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE, CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE_OFF);
                    cr.set(CaptureRequest.LENS_OPTICAL_STABILIZATION_MODE, CaptureRequest.LENS_OPTICAL_STABILIZATION_MODE_OFF);
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
                        cr.set(CaptureRequest.STATISTICS_OIS_DATA_MODE, CaptureRequest.STATISTICS_OIS_DATA_MODE_OFF);
                    }

                    for (Surface al : als) {
                        cr.addTarget(al);
                    }

                    CameraCaptureSession.StateCallback sessionCallback = new CameraCaptureSession.StateCallback() {
                        @Override
                        public void onConfigured(@NonNull CameraCaptureSession session) {
                            if(closed) return;
                            if (recording) codec.start();
                            try {
                                CameraCaptureSession.CaptureCallback cc = new CameraCaptureSession.CaptureCallback() {
                                    @Override
                                    public void onCaptureCompleted(@NonNull CameraCaptureSession session, @NonNull CaptureRequest request, @NonNull TotalCaptureResult result) {
                                        super.onCaptureCompleted(session, request, result);

                                        long frame = result.getFrameNumber();
                                        if(estimateStarttime == 0){
                                            Long ts = result.get(CaptureResult.SENSOR_TIMESTAMP);
                                            estimateStarttime = ts!=null?ts:0;
                                        }
                                        Long timestamp = result.get(CaptureResult.SENSOR_TIMESTAMP);
                                        Long exposuretime = result.get(CaptureResult.SENSOR_EXPOSURE_TIME);
                                        Long shutterskew = result.get(CaptureResult.SENSOR_ROLLING_SHUTTER_SKEW);
                                        Long frameduration = result.get(CaptureResult.SENSOR_FRAME_DURATION);

                                        Long clock_timing_monotonic = System.nanoTime();
                                        Long clock_timing_realtime = SystemClock.elapsedRealtimeNanos();
                                        Long clock_timing_millis = System.currentTimeMillis();


                                        if(encodedFrameCallback != null){
                                            encodedFrameCallback.pushFrameMeta("comparable.realtime_time", frame, clock_timing_realtime);
                                            encodedFrameCallback.pushFrameMeta("comparable.monotonic_time", frame, clock_timing_monotonic);
                                            encodedFrameCallback.pushFrameMeta("comparable.millis_time", frame, clock_timing_millis);

                                            if(timestamp != null) encodedFrameCallback.pushFrameMeta("sensor.timestamp", frame, timestamp);
                                            if(exposuretime != null) encodedFrameCallback.pushFrameMeta("sensor.exposure_time", frame, exposuretime);
                                            if(shutterskew != null) encodedFrameCallback.pushFrameMeta("sensor.rolling_shutter_skew", frame, shutterskew);
                                            if(frameduration != null) encodedFrameCallback.pushFrameMeta("sensor.frame_duration", frame, frameduration);
                                        }
                                    }

                                };
                                if(closed) return;
                                Handler h = new Handler(captureLooperThread.getLooper());
                                //if(finalSc != null) finalSc.setCaptureCallback(cc, h);
                                session.setRepeatingRequest(cr.build(), cc, h);
                            } catch (CameraAccessException | IllegalStateException e) {
                                e.printStackTrace();
                                onFailure.run();
                            }
                        }

                        @Override
                        public void onConfigureFailed(@NonNull CameraCaptureSession session) {
                            Timber.e("onConfigureFailed", new Exception("Failed to configure camera??"));
                            onFailure.run();
                        }
                    };

                    camera.createCaptureSession(als, sessionCallback, new Handler(recordLooperThread.getLooper()));
                } catch (CameraAccessException | IllegalStateException e) {
                    e.printStackTrace();
                    onFailure.run();
                }
            }

            @Override
            public void onDisconnected(@NonNull CameraDevice camera) { }

            @Override
            public void onError(@NonNull CameraDevice camera, int error) {
                Timber.e("Camera error "+error, new Exception("Got a camera error, "+error));
            }
        };

        cameraManager.openCamera(targetCamera, cameraCallback, new Handler(stateLooperThread.getLooper()));
        return true;
    } catch (CameraAccessException e) {
        e.printStackTrace();
        return false;
    }
}
public void stopRecording(){
    closed = true;
    if(currentCamera != null) currentCamera.close();
    currentCamera = null;
    System.out.println("estimateEndTime: " + estimateTimestamp);

    codec.stop();
    codec.reset();
}

Solution

  • After digging deeper into my code I found that the problem is not with the MediaCode settings/configurations. But the problem was in storing the file as you can see below:

    data.position(offset);
    data.limit(offset + size);//This causes the stored video to be longer than it is in reality. 
            try {
                while (data.remaining() > 0)
                    h264Stream.getChannel().write(data);
            } catch (IOException e) {}
    

    removing the line data.limit(offset + size); fixed my problem. The offset is

    The start-offset of the data in the buffer.

    And the size is

    The amount of data (in bytes) in the buffer. If this is 0, the buffer has no data in it and can be discarded. The only use of a 0-size buffer is to carry the end-of-stream marker.

    I still don't understand the repercussions of removing that line but I am going to do extensive tests. Please drop comments or a better answer if you have knowledge about it. I will change the correct answer!