現在主流的播放器都提供了錄製GIF圖的功能。GIF圖就是將一幀幀連續的影像連續的展示出來,形成動畫。所以生成GIF圖可以分成兩步,首先要獲取一組連續的影像,第二步是將這組影像合成一個GIF檔案。關於GIF檔案合成,網路上有很多開源的工具類。我們今天主要來看下如何從播放器中獲取一組截圖。話不多說先了解下視訊播放的流程。
1.1、解碼後從影像幀池中獲取影像幀資料
從上面流程圖中可以看出,截圖只需要獲取解碼後的影像幀資料即可,即從影像幀池中拿出指定幀影像就好了。當我們使用FFmpeg軟解碼播放時,影像幀池在我們自己的程式碼裡,所以我們可以拿到任意幀。但是但我們使用系統MediaCodec
介面硬解碼播放視訊時,視訊解碼都是系統的MediaCodec
模組來做的,如果我們想要從MediaCodec
裡拿出影像幀資料來就得研究MediaCodec
的介面了。
MediaCodec
的工作流程如上圖所示。MediaCodec類是Android底層多媒體框架的一部分,它用來訪問底層編解碼元件,通常與MediaExtractor、MediaSync、Image、Surface和AudioTrack等類一起使用。
簡單的說,編解碼器(Codec)的功能就是把輸入的原始資料處理成可用的輸出資料。它使用一組input buffer
和一組output buffer
來非同步的處理資料。一個簡單的資料處理流程大致分三步:
- 從
MediaCodec
獲取一個input buffer
,然後把從資料來源中拆包出來的原始資料填到這個input buffer
中; - 把填滿原始資料的
input buffer
送到MediaCodec
中,MediaCodec
會將這些原始資料解碼成影像幀資料,並將這些影像幀資料放入到output buffer
中; - 從
MediaCodec
中獲取一個有可用影像幀資料output buffer
,然後可以將output buffer
輸出到surface
或者bitmap
中就可以渲染到螢幕或者儲存在圖片檔案中了。
MediaFormat format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, mWidth, mHeight);
String mime = format.getString(MediaFormat.KEY_MIME);
// 建立視訊解碼器,配置解碼器
MediaCodec mVideoDecoder = MediaCodec.createDecoderByType(mime);
mVideoDecoder.configure(format, surface, null, 0);
// 1、獲取input buffer,將原始視訊資料包塞到input buffer中
int inputBufferIndex = mVideoDecoder.dequeueInputBuffer(50000);
ByteBuffer buffer = mVideoDecoder.getInputBuffer(inputBufferIndex);
// 2、將帶有原始視訊資料的input buffer送到MediaCodec中解碼,解碼資料會放置到output buffer中
mVideoDecoder.queueInputBuffer(mVideoBufferIndex, 0, size, presentationTime, 0);
// 3、獲取帶有視訊幀資料的output buffer,釋放output buffer時會將資料渲染到在配置解碼器時設定的surface上
int outputBufferIndex = mVideoDecoder.dequeueOutputBuffer(info, 10000);
mVideoDecoder.releaseOutputBuffer(outputBufferIndex, render);
複製程式碼
上面是使用MediaCodec
播放視訊的基本流程。我們的目標是在這個播放過程中獲取到一幀視訊圖片。從上面的過程可以看到在獲取視訊幀資料的output buffer
方法dequeueOutputBuffer
返回的不是一個buffer
物件,而只是一個buffer
序列號,渲染時只將這個outputBufferIndex
傳遞給MediaCodec
,MediaCodec
就會將對應index的渲染到初始配置是設定的surface中。要實現截圖就得獲取到output buffer
的資料,我們現在需要的一個通過outputBufferIndex
獲取到output buffer
方法。看了下MediaCodec
的介面還真有這樣的方法,詳細如下:
/**
* Returns a read-only ByteBuffer for a dequeued output buffer
* index. The position and limit of the returned buffer are set
* to the valid output data.
*
* After calling this method, any ByteBuffer or Image object
* previously returned for the same output index MUST no longer
* be used.
*
* @param index The index of a client-owned output buffer previously
* returned from a call to {@link #dequeueOutputBuffer},
* or received via an onOutputBufferAvailable callback.
*
* @return the output buffer, or null if the index is not a dequeued
* output buffer, or the codec is configured with an output surface.
*
* @throws IllegalStateException if not in the Executing state.
* @throws MediaCodec.CodecException upon codec error.
*/
@Nullable
public ByteBuffer getOutputBuffer(int index) {
ByteBuffer newBuffer = getBuffer(false /* input */, index);
synchronized(mBufferLock) {
invalidateByteBuffer(mCachedOutputBuffers, index);
mDequeuedOutputBuffers.put(index, newBuffer);
}
return newBuffer;
}
複製程式碼
注意介面文件對返回值的描述 return the output buffer, or null if the index is not a dequeued output buffer, or the codec is configured with an output surface.
也就是說如果我們在初始化MediaCodec
時設定了surface
,那麼我們通過這個介面獲取到的output buffer都是null。原因是當我們給MediaCodec
時設定了surface
作為資料輸出物件時,output buffer直接使用的是native buffer沒有將資料對映或者拷貝到ByteBuffer
中,這樣會使影像渲染更加高效。播放器主要的最主要的功能還是要播放,所以設定surface是必須的,那麼在拿不到放置解碼後視訊幀資料的ByteBuffer的情況下,我們改怎麼實現截圖功能呢?
1.2、渲染後從View中獲取影像幀資料
這時我們轉換思路,既然硬解碼後的影像幀資料不方便獲取(方案1),那麼我們能不能等到影像幀資料渲染到View上後再從View中去獲取資料呢(方案2)?
我們視訊播放器使用的SurfaceVIew
+ MediaCodec
的方式來實現的。那我們來調研下從SurfaceVIew
中獲取影像的技術實現。然後我們就有了這篇文章《為啥從SurfaceView中獲取不到圖片?》。結束就是從SurfaceView
無法獲取到渲染出來的影像。為了獲取視訊截圖我們換用TextureView
+ MediaCodec
的方式來實現播放。從TextureView
中獲取當前顯示幀影像方法如下。
/**
* <p>Returns a {@link android.graphics.Bitmap} representation of the content
* of the associated surface texture. If the surface texture is not available,
* this method returns null.</p>
*
* <p>The bitmap returned by this method uses the {@link Bitmap.Config#ARGB_8888}
* pixel format.</p>
*
* <p><strong>Do not</strong> invoke this method from a drawing method
* ({@link #onDraw(android.graphics.Canvas)} for instance).</p>
*
* <p>If an error occurs during the copy, an empty bitmap will be returned.</p>
*
* @param width The width of the bitmap to create
* @param height The height of the bitmap to create
*
* @return A valid {@link Bitmap.Config#ARGB_8888} bitmap, or null if the surface
* texture is not available or width is <= 0 or height is <= 0
*
* @see #isAvailable()
* @see #getBitmap(android.graphics.Bitmap)
* @see #getBitmap()
*/
public Bitmap getBitmap(int width, int height) {
if (isAvailable() && width > 0 && height > 0) {
return getBitmap(Bitmap.createBitmap(getResources().getDisplayMetrics(),
width, height, Bitmap.Config.ARGB_8888));
}
return null;
}
複製程式碼
到目前為止完成了一小步,實現了從播放器中獲取一張影像的功能。接下來我們看下如何獲取一組影像。
1.3 獲取一組連續的影像
單張影像都獲取成功了,獲取多張影像還難嗎?由於我們獲取圖片的方式是等到影像在View中渲染出來後再從View中獲取的。那麼問題來了,如要生成一張播放時長為5s的GIF,收集這組影像是不是真的得持續5s,讓5s內所有資料都在View上渲染了一次才能收集到呢?這種體驗肯定是不允許的,為此我們使用類似倍速播放的功能,讓5s內的影像資料快速的在View上渲染一遍,以此來快速的獲取5s類的影像資料。
if (isScreenShot) {
// GIF圖不需要所有幀資料,定義每秒5張,那麼每200ms渲染一幀資料即可
render = (info.presentationTimeUs - lastFrameTimeMs) > 200;
}else{
// 同步音訊的時間
render = mediaPlayer.get_sync_info(info.presentationTimeUs) != 0;
}
if (render) {
lastFrameTimeMs = info.presentationTimeUs;
}
mVideoDecoder.releaseOutputBuffer(mVideoBufferIndex, render);
複製程式碼
如上述程式碼所示,在截圖模式下影像渲染不在與音訊同步,這樣就實現了影像快速渲染。另外就是GIF圖每秒只有幾張圖而已,這裡定義是5張,那麼只需要從視訊源的每秒30幀資料中選出5張圖渲染出來即可。這樣我們就快速的獲取到了5s的影像資料。
獲取到所需的影像資料以後,剩下的就是合成GIF檔案了。那這樣就實現了在使用MediaCodec
硬解碼播放視訊的情況下生成GIF圖的需求。