固阳音箱协会

Android视频处理之MediaCodec-3-播放视频

奇卓社2018-12-04 16:58:31

    

        第一篇文章介绍过:在Android中播放视频很简单,我们可以使用MediaPlayer+SurfaceView或者VideoView就可以。
        这篇文章,重点来讲解下MediaCodec解码视频到TextureView播放视频是一个什么样的流程。

视频

1.TextureView
首先得准备一个TextureView,由它提供一个SurfaceTexture给MediaCodec作为视频数据输出的载体。

mMovieView = (TextureView) findViewById(R.id.movie_display);
mMovieView.setSurfaceTextureListener(this);

然后需要在SurfaceTexture准备好之后,初始化解码器,所以需要在onSurfaceTextureAvailable里面实现。

@Overridepublic void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {    
   //拿到SurfaceTexture,作为视频解码器的数据输出。注意:在SurfaceTexture available之后,再进行解码。    mSurfaceTexture = surface;    //开始解码    decode(); }

2.获取视频参数
通过MediaExtractor解析视频,获取视频格式、视频轨道、视频高宽等参数。

mMediaExtractor = new MediaExtractor();
mMediaExtractor.setDataSource("/sdcard/test.mp4");
int trackCount = mMediaExtractor.getTrackCount();
for (int i = 0; i < trackCount; i++) {    MediaFormat format = mMediaExtractor.getTrackFormat(i);    String mime = format.getString(MediaFormat.KEY_MIME);    
   if (mime.startsWith("video/avc")) {        mVideoFormat = format;        mVideoTrackIndex = i;        mMimeType = mime;        break;     } }
int width = mVideoFormat.getInteger(MediaFormat.KEY_WIDTH);
int height = mVideoFormat.getInteger(MediaFormat.KEY_HEIGHT);

3.开始解码
当然首先需要选择解码的轨道,MediaExtractor知道需要读取哪个轨道的数据,然后再让MediaCodec做解码的操作。

mMediaExtractor.selectTrack(mVideoTrackIndex);

MediaCodec通过异步方式解码。编解码的步骤就是取出一个空buffer,然后给buffer填充数据,再送给编解码器,结果编解码处理好数据之后,再输出buffer给使用者使用。异步和同步的方式都很类似,核心都是利用buffer来处理数据。
同步的方式是使用者自己轮询去查询是否有可用的buffer,而异步则是等待buffer可用后,MedaiCodec回调。
有两个方法:

- onInputBufferAvailable(MediaCodec mc, int inputBufferId)

这个地方就是说明有可以使用的输入buffer了,使用者可以在这个地方取出buffer,然后往里面填充数据。

ByteBuffer decoderInputBuffer = mDecoder.getInputBuffer(inputBufferId);

数据则是通过MediaExtractor的readSampleData把数据读取到buffer中去,然后通过queueInputBuffer方法把数据送会给编解码器处理,

mDecoder.queueInputBuffer(inputBufferId,0,size,presentationTime,mMediaExtractor.getSampleFlags());

如果MediaExtractor读不到数据了,则说明视频流已经到了结尾,需要告知解码器,所以要设置end of stream 标记
MediaCodec.BUFFER_FLAG_END_OF_STREAM

 mDecoder.queueInputBuffer(inputBufferId,0,0,0,MediaCodec.BUFFER_FLAG_END_OF_STREAM);
- onOutputBufferAvailable(@NonNull MediaCodec codec, int index, @NonNull MediaCodec.BufferInfo info)

解码器处理好数据之后,则会调用此方法,我们需要在这个方法里面取出数据使用,通过调用MediaCodec的releaseOutputBuffer把数据输出到SurfaceTexture。如果视频已经读到结尾,则调用MediaExtractor的seekTo,跳转到视频开头,并且重置解码器。注意:在异步模式下,重置解码器,不仅需要调用MediaCodec的flush,还需要重新调用start()方法,这样异步解码才会重新开始。

//如果视频数据已经读到结尾,则调用MediaExtractor的seekTo,跳转到视频开头,并且重置解码器。

boolean reset = ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0);
if (reset) {    mMediaExtractor.seekTo(0, MediaExtractor.SEEK_TO_CLOSEST_SYNC);    mDecoder.flush();    // reset decoder state    mDecoder.start(); }
视频帧显示

正常来说,我们一般看的视频的帧率大概是30,也就是说一秒钟播放30帧,这意味着每一帧的间隔是33.333….ms。一般视频单帧解码可能只需要几毫秒,如果我们一解码出来就给Surface显示,是不合理的,那视频就没法看了,全部是快进。所以我们需要做帧等待,那等待多久合适呢?有的同学可能说,等待30ms就行了,那不是标准吗?
对,30帧是标准,但是不代表每个视频的帧率都是30帧,这个完全是由视频生产者去控制的。那播放设备怎么做到去正常播放每个不同帧率的视频呢?这里就需要了解到PTSDTS两个概念。

DTS、PTS 的概念如下所述:

DTS(Decoding Time Stamp):即解码时间戳,这个时间戳的意义在于告诉播放器该在什么时候解码这一帧的数据。
PTS(Presentation Time Stamp):即显示时间戳,这个时间戳用来告诉播放器该在什么时候显示这一帧的数据。

DTS主要用于视频的解码,在解码阶段使用.PTS主要用于视频的同步和输出.在display的时候使用.在没有B frame的情况下.DTS和PTS的输出顺序是一样的。

在做MediaCodec解码的时候,我们只关心PTS即画面显示时间戳,解码出来的BufferInfo的presentationTimeUs就是指当前帧显示的时间(单位微秒),它是相对于0开始的视频播放时间。所以我们在做视频解码播放的时候,要从开始解码计时,以系统时间为时间线,看当前系统时间是否到了当前帧应该显示的时间,如果到了就release给Surface进行显示,反之继续等待。详细代码如下:

long currentPos = info.presentationTimeUs;        //视频数据间隔处理
        if (mOnlinePrevMonoUsec == 0 ) {
            mOnlinePrevMonoUsec = System.nanoTime() / 1000;
            mOnlinePrevPresentUsec = info.presentationTimeUs;
        } else {            //当前帧显示时间-上一帧显示时间,即为帧间隔
            long delta = (info.presentationTimeUs - mOnlinePrevPresentUsec);            
           if (delta < 0) {                delta = 0;            }            //上一帧显示时间+帧间隔即为预计当前帧显示时间            long desiredUsec = mOnlinePrevMonoUsec + (long) (delta / mSpeed);            
           long nowUsec = System.nanoTime() / 1000;            
           while (nowUsec < (desiredUsec - 100)) {
               // Sleep until it's time to wake up.  To be responsive to "stop" commands                // we're going to wake up every half a second even if the sleep is supposed                // to be longer (which should be rare).  The alternative would be                // to interrupt the thread, but that requires more work.                //                // The precision of the sleep call varies widely from one device to another;                // we may wake early or late.  Different devices will have a minimum possible                // sleep time. If we're within 100us of the target time, we'll probably                // overshoot if we try to sleep, so just go ahead and continue on.                long sleepTimeUsec = desiredUsec - nowUsec;                
               if (sleepTimeUsec > 500000) {                    sleepTimeUsec = 500000;                }                
               if (sleepTimeUsec > 0) {                    
                   try {                        Thread.sleep(sleepTimeUsec / 1000, (int) (sleepTimeUsec % 1000) * 1000);                    } catch (InterruptedException ignored) {                    }                }                nowUsec = System.nanoTime() / 1000;            }            mOnlinePrevMonoUsec += (long) (delta / mSpeed);            mOnlinePrevPresentUsec += delta;        }

下面是视频播放Demo的完整代码

package demo.mediacodec.xueting.com.mediacodecdemo;
import android.graphics.SurfaceTexture;
import android.media.MediaCodec;
import android.media.MediaCodecInfo;
import android.media.MediaExtractor;
import android.media.MediaFormat;
import android.os.Build;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.RequiresApi;
import android.support.v4.app.FragmentActivity;
import android.util.Log;
import android.view.Surface;
import android.view.TextureView;
import android.widget.LinearLayout;
import java.io.IOException;
import java.nio.ByteBuffer;
public class Mp4PlayerActivity extends FragmentActivity implements TextureView.SurfaceTextureListener {    
   private TextureView mMovieView;    
   private SurfaceTexture mSurfaceTexture;    
   private MediaExtractor mMediaExtractor;    
   private MediaCodec mDecoder;    
   private String mMimeType;    
   private MediaFormat mVideoFormat;    
   private int mVideoTrackIndex = -1;    
   private int mWidth = 720;    
   private int mHeight = 1280;    
   @Override    protected void onCreate(@Nullable Bundle savedInstanceState) {        
       super.onCreate(savedInstanceState);        setContentView(R.layout.activity_main);        //TextureView,用来渲染视频帧        mMovieView = (TextureView) findViewById(R.id.movie_display);        mMovieView.setSurfaceTextureListener(this);        initExtractor();    }    
   @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)    
   @Override    public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {        
       //拿到SurfaceTexture,作为视频解码器的数据输出。注意:在SurfaceTexture available之后,再进行解码。        mSurfaceTexture = surface;        //开始解码        decode();    }
   @Override    public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) {    }    
   @Override    public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {        
       return false;    }    
   @Override    public void onSurfaceTextureUpdated(SurfaceTexture surface) {    }    
   //初始化,通过MediaExtractor从视频文件中获取视频相关参数    private void initExtractor() {        
       try {            mMediaExtractor = new MediaExtractor();            mMediaExtractor.setDataSource("/sdcard/test.mp4");            
           int trackCount = mMediaExtractor.getTrackCount();            
           for (int i = 0; i < trackCount; i++) {                MediaFormat format = mMediaExtractor.getTrackFormat(i);                String mime = format.getString(MediaFormat.KEY_MIME);              
               if (mime.startsWith("video/avc")) {                    mVideoFormat = format;                    mVideoTrackIndex = i;                    mMimeType = mime;                    
                   break;                }            }            
           int width = mVideoFormat.getInteger(MediaFormat.KEY_WIDTH);            
           int height = mVideoFormat.getInteger(MediaFormat.KEY_HEIGHT);            mWidth = width;            mHeight = height;            
           /*                据视频自身的高宽来设置显示的高宽,此处只是考虑竖屏无角度的情况。                视频本身是可以带有角度信息,所以如果要真正做一个完美的播放器,需要                根据角度信息来设置高宽            */            runOnUiThread(new Runnable() {                
               @Override                public void run() {                    LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(mWidth, mHeight);                    mMovieView.setLayoutParams(params);                }            });        } catch (IOException e) {            mMediaExtractor.release();        }    }    
   @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)    
   private void decode() {        
       try {            //开始解码,需要选择视频轨道            mMediaExtractor.selectTrack(mVideoTrackIndex);            //根据视频格式,创建对应的解码器            mDecoder = MediaCodec.createDecoderByType(mMimeType);     //API 21以上,通过异步模式进行解码            mDecoder.setCallback(new MediaCodec.Callback() {                
               @Override                public void onInputBufferAvailable(MediaCodec mc, int inputBufferId) {                    
                   //从解码器中拿到输入buffer,让用户填充数据                    ByteBuffer decoderInputBuffer = mDecoder.getInputBuffer(inputBufferId);                    
                   boolean mVideoExtractorDone = false;                    
                   while (!mVideoExtractorDone) {                        //从视频中读取数据                        int size = mMediaExtractor.readSampleData(decoderInputBuffer, 0);                        
                       long presentationTime = mMediaExtractor.getSampleTime();                        
                       if (size >= 0) {//如果读取到数据,把buffer给回到解码器                            mDecoder.queueInputBuffer(                                    inputBufferId,                                    0,                                    size,                                    presentationTime,                                    mMediaExtractor.getSampleFlags());                        }                        mVideoExtractorDone = !mMediaExtractor.advance();                        
                       if (mVideoExtractorDone) {
                       //当然,如果取下一帧数据失败的时候,则也把buffer扔回去,带上end of stream标记,告知解码器,视频数据已经解析完                            mDecoder.queueInputBuffer(                                    inputBufferId,
                                   0,
                                   0,
                                   0,                                    MediaCodec.BUFFER_FLAG_END_OF_STREAM);                            }                        
                                                           if (size >= 0) {                                 break;                        }                    }                }              
                                                   @Override                public void onOutputBufferAvailable(@NonNull MediaCodec codec, int index, @NonNull MediaCodec.BufferInfo info) {                    
                                                       if ((info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {                        codec.releaseOutputBuffer(index, false);                                       return;                    }                                    /*                        这个地方先暂时认为每一帧的间隔是30ms,正常情况下,需要根据实际的视频帧的时间标记来计算每一帧的时间点。                        因为视频帧的时间点是相对时间,正常第一帧是0,第二帧比如是第5ms。                        基本思路是:取出第一帧视频数据,记住当前时间点,然后读取第二帧视频数据,再用当前时间点减去第一帧时间点,看看相对时间是多少,有没有                        达到第二帧自己带的相对时间点。如果没有,则sleep一段时间,然后再去检查。直到大于或等于第二帧自带的时间点之后,进行视频渲染。                     */                    try {                        Thread.sleep(30);                    } catch (InterruptedException e) {                    }                    codec.releaseOutputBuffer(index, true); //如果视频数据已经读到结尾,则调用MediaExtractor的seekTo,跳转到视频开头,并且重置解码器。                    boolean reset = ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0);                    
                                                       if (reset) {                        mMediaExtractor.seekTo(0, MediaExtractor.SEEK_TO_CLOSEST_SYNC);                        mDecoder.flush();    // reset decoder state                        mDecoder.start();                    }                }                
                                                   @Override                public void onError(@NonNull MediaCodec codec, @NonNull MediaCodec.CodecException e) {                    codec.reset();                }                
                                                   @Override                public void onOutputFormatChanged(MediaCodec mc, MediaFormat format) {
                                                       // Subsequent data will conform to new format.                    // Can ignore if using getOutputFormat(outputBufferId)                    mVideoFormat = format; // option B                }            });            
                                               //设置渲染的颜色格式为Surface。            mVideoFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT,                    MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);            
                                               //设置解码数据到指定的Surface上。            mDecoder.configure(mVideoFormat, new Surface(mSurfaceTexture), null, 0);            mDecoder.start();        } catch (IOException e) {            e.printStackTrace();        }    }    
                                       @Override    protected void onDestroy() {                         super.onDestroy();                                     if (mDecoder != null) {            mDecoder.stop();            mDecoder.release();        }    } }

音频

        上面的代码只是解码了视频轨道的信息,正常一个视频文件包含视频和音频两条轨道信息。音频的解码类似视频,要实现音频的播放,结合AudioTrack就行了。步骤如下:
1.初始化AudioTrack
        根据声道数和采样率初始化AudioTrack,这两个参数都可以通过MediaExtractor获取到。其他的参数基本播放都一样,大家可以参考AudioTrack官方文档。
特别说明下Sonic是一个开源库,提供音频的速度和音调等能力控制,在音频播放和音频合成的时候都用的上

private void prepareAudio(int channelCount, int sampleRate) {        // prepare AudioTrack
        int bufferSize = AudioTrack.getMinBufferSize(sampleRate, AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_16BIT);        
       int channelConfig = channelCount == 2 ? AudioFormat.CHANNEL_OUT_STEREO : AudioFormat.CHANNEL_OUT_MONO;        mSonic = new Sonic(sampleRate, channelCount);        mAudioTrack = new AudioTrack(                AudioManager.STREAM_MUSIC,                sampleRate,                channelConfig,                AudioFormat.ENCODING_PCM_16BIT,                bufferSize * 2,                AudioTrack.MODE_STREAM        );        mAudioTrack.setStereoVolume(mMute ? 0 : 1, mMute ? 0 : 1);        mAudioTrack.play();    }

2.播放解码器数据
当解码器解析到音频数据后,直接扔给AudioTrack,然后就可以播放了

if (chunk != null) {    
   if (BuildConfig.DEBUG) {        Log.d(TAG, "onBufferAvailable:" + chunk.length);     }    //通过Sonic来处理数据    //设置播放速度     mSonic.setSpeed(mSpeed);     mSonic.putBytes(chunk, chunk.length);    
    int available = mSonic.availableBytes();    
    if (available > 0) {        
        byte[] modifiedSamples = new byte[available];
        //把数据写入AudioTrack,就可以播放了         mSonic.receiveBytes(modifiedSamples, available);         mAudioTrack.write(modifiedSamples, 0, available);      } }

Copyright © 固阳音箱协会@2017