android視訊播放截圖並製作成gif圖片
導言:
根據文章標題,按三步走,一、視訊播放;二、連續截圖;三、轉換成gif。視訊播放很自然想到用MediaPlayer或者VideoView,但我在這裡踩了幾個坑,寫在這裡也希望別人少走點彎路。首先,是MediaPlayer+SurfaceView的坑,如果只是想實現視訊播放,那麼用這種方式確實不錯,但是並不能實現截圖,SurfaceView一般通過getHolder().lockCanvas()可以獲取到Canvas,那麼通過這個Canvas不就可以獲取到它的bitmap了嗎?錯了!那只是針對普通的靜態畫面而言,像視訊播放這樣的動態畫面來說,一開始播放,是不允許呼叫這個介面的,否則會出現SurfaceHolder: Exception locking surface和java.lang.IllegalArgumentException的錯誤。那麼用下面這種方式呢:
View view = activity.getWindow().getDecorView();
view.setDrawingCacheEnabled(true);
view.buildDrawingCache();
bitmap = view.getDrawingCache();
依然不行,SurfaceView部分截取出來的是黑屏,原因很多文章講過我就不重複了。那麼用VideoView呢?事實上,VideoView也是繼承自SurfaceView,所以一樣會截圖失敗。有人會說用MediaMetadataRetriever就可以很方便截圖了啊,管它是VideoView還是SurfaceView都能截。是的,MediaMetadataRetriever跟VideoView或者SurfaceView一點關係都沒有,它只需獲取到視訊檔案根本不需要視訊播放出來就能通過getFrameAtTime(long timeUs)這個介面獲取指定時間的視訊。但是,我還想說,但是,MediaMetadataRetriever獲取的是指定位置附近的關鍵幀,而視訊檔案的關鍵幀,就我所測試,2-5秒才有一個關鍵幀,所以如果通過getFrameAtTime介面獲取2-5秒內的幾十張bitmap,你會發現每張都是一樣的,真是令人崩潰,根本無法滿足製作gif需要的幀率。
那麼用什麼方式播放才能連續獲取到正確的截圖呢?答案是MediaPlayer+TextureView的方式。
一、視訊播放
activity先實現SurfaceTextureListener介面,在onCreate的時候呼叫TextureView的setSurfaceTextureListener(TextureVideoActivity.this)即可,在TextureView初始化完成之後,會自動呼叫SurfaceTextureListener的介面方法onSurfaceTextureAvailable,在這裡進行MediaPlayer的初始化並開始播放:
@Override public void onSurfaceTextureAvailable(SurfaceTexture surfaceTexture, int width, int height) { //surface不能重複使用,每次必須重新new一個 surface = new Surface(surfaceTexture); if (!TextUtils.isEmpty(mUrl)) { startPlay(); } } private void startPlay() { if (mMediaPlayer == null) { mMediaPlayer = new MediaPlayer(); } //mUrl是本地視訊的路徑地址 mMediaPlayer.setDataSource(this, Uri.parse(mUrl)); mMediaPlayer.setSurface(surface); mMediaPlayer.setLooping(false); mMediaPlayer.prepareAsync(); mMediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { @Override public void onPrepared(MediaPlayer mediaPlayer) { mediaPlayer.start(); } }); }
看,很簡單,這樣就可以開始進行播放了。需要注意的是,介面跳轉或切換到後臺再切回來,就會再次呼叫該介面,而原先的Surface不能再用,需要重新new一個。
二、截圖
截圖非常簡單,只需要呼叫TextureView的getBitmap()方法就可以,連續快速地呼叫都沒有問題。
三、轉換成gif
//另開執行緒並執行
GIFEncoder encoder = new GIFEncoder();
encoder.init(bitmaps.get(0));
encoder.setFrameRate(1000 / DURATION);
//filePath為本地gif儲存路徑
encoder.start(filePath);
for (int i = 1; i < bitmaps.size(); i++) {
encoder.addFrame(bitmaps.get(i));
}
encoder.finish();
bitmaps是在定時迴圈DURATION下總共取得的bitmap列表,這樣一個gif就製作完成了。但是這樣執行的速度會非常慢,三四十張bitmap的轉換就需要好幾分鐘,顯然不行,於是我參照GifEncoder類再寫了一個GifEncoderWithSingleFrame的類,將每張bitmap各自轉換成一張臨時的.partgif檔案,待所有的bitmap都轉換完之後再合併成一張gif圖片,程式碼稍微長了些:
List<String> fileParts = new ArrayList<>();
ExecutorService service = Executors.newCachedThreadPool();
final CountDownLatch countDownLatch = new CountDownLatch(bitmaps.size());
for (int i = 0; i < bitmaps.size(); i++) {
final int n = i;
final String fileName = getExternalCacheDir() + File.separator + (n + 1) + ".partgif";
fileParts.add(fileName);
Runnable runnable = new Runnable() {
@Override
public void run() {
GIFEncoderWithSingleFrame encoder = new GIFEncoderWithSingleFrame();
encoder.setFrameRate(1000 / frameRate / 1.5f);
Log.e(TAG, "總共" + bitmaps.size() + "幀,正在新增第" + (n + 1) + "幀");
if (n == 0) {
encoder.addFirstFrame(fileName, bitmaps.get(n));
} else if (n == bitmaps.size() - 1) {
encoder.addLastFrame(fileName, bitmaps.get(n));
} else {
encoder.addFrame(fileName, bitmaps.get(n));
}
countDownLatch.countDown();
}
};
service.execute(runnable);
}
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
handler.post(new Runnable() {
@Override
public void run() {
Toast.makeText(TextureVideoActivity.this, "gif初始化成功,準備合併", Toast.LENGTH_SHORT).show();
}
});
SequenceInputStream sequenceInputStream = null;
FileOutputStream fos = null;
try {
Vector<InputStream> streams = new Vector<InputStream>();
for (String filePath : fileParts) {
InputStream inputStream = new FileInputStream(filePath);
streams.add(inputStream);
}
sequenceInputStream = new SequenceInputStream(streams.elements());
File file = new File(getExternalCacheDir() + File.separator + System.currentTimeMillis() + ".gif");
if (!file.exists()) {
file.createNewFile();
}
fos = new FileOutputStream(file);
byte[] buffer = new byte[1024];
int len = 0;
while ((len = sequenceInputStream.read(buffer)) != -1) {
fos.write(buffer, 0, len);
}
fos.flush();
fos.close();
sequenceInputStream.close();
handler.post(new Runnable() {
@Override
public void run() {
Toast.makeText(TextureVideoActivity.this, "gif製作完成", Toast.LENGTH_SHORT).show();
}
});
for (String filePath : fileParts) {
File f = new File(filePath);
if (f.exists()) {
f.delete();
}
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
if (fos != null) {
try {
fos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (sequenceInputStream != null) {
try {
sequenceInputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
稍微解釋下,這裡用了ExecutorService執行緒池和CountDownLatch執行緒控制工具類,保證所有執行緒執行完再執行countDownLatch.await()下面的程式碼。gif的第一幀和最後一幀分別需要加入檔案頭和結束符等,所以需要區別對待,分別呼叫了addFirstFrame和addLastFrame,其他幀呼叫addFrame即可。然後利用SequenceInpustream這個類將所有.partgif檔案統一加入到輸入流,最終再用FileOutputStream輸出來就可以。經過這樣修改之後,gif的轉換時間從幾分鐘縮短到了幾秒鐘(畫素高一點圖片數量多一點可能也需要20S左右)。
細節注意:
從TextureView.getBimap()獲取到的bitmap畫素因不同手機而不同,如果不做處理直接加入bitmap列表很容易引起OOM,所以需要對bitmap先進行尺寸壓縮:
Bitmap bitmap = mTexureView.getBitmap();
String path = getExternalCacheDir() + File.separator + String.valueOf(count + 1) + ".jpg";
BitmapSizeUtils.compressSize(bitmap, path, 720, 80);
Bitmap bmp = BitmapFactory.decodeFile(path);
//壓縮後再新增
bitmaps.add(bmp);
public static void compressSize(Bitmap bitmap, String toFile, int targetWidth, int quality) {
try {
int bitmapWidth = bitmap.getWidth();
int bitmapHeight = bitmap.getHeight();
int targetHeight = bitmapHeight * targetWidth / bitmapWidth;
Bitmap resizeBitmap = Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true);
File myCaptureFile = new File(toFile);
FileOutputStream out = new FileOutputStream(myCaptureFile);
if (resizeBitmap.compress(Bitmap.CompressFormat.JPEG, quality, out)) {
out.flush();
out.close();
}
if (!bitmap.isRecycled()) {
bitmap.recycle();
}
if (!resizeBitmap.isRecycled()) {
resizeBitmap.recycle();
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException ex) {
ex.printStackTrace();
}
}
壓縮成寬度720畫素,這樣壓縮出來的圖片比較清晰,當然最終的gif圖片也會比較大,30-40張bitmap轉換成的gif大概有3-4M左右,如果想讓gif小一點,寬度設定成400左右也就夠了。
上效果圖:
由25張解析度440*247的bitmap合併而成,大小1.36M
直接由本地傳的圖片居然不動,有知道怎麼傳的請告訴我!!!
最後,上Github原始碼, 原始碼還包括surfaceview和videoview的播放方式的程式碼,想看gif生成程式碼的只需要看TextureVideoActivity這個介面就可以了。
PS:後續測試,在執行GIFEncoderWithSingleFrame類的提取畫素值方法getImagePixels時,因為涉及到密集型資料運算,CPU會飆高到90%左右,而不同手機因為CPU型號不同,轉換100張440*260畫素的bitmap在執行到這個方法時,有些手機如小米運算速度仍然非常快,只需要幾秒鐘,有些手機如華為、三星速度就慢成狗了,達到兩分鐘以上,忍無可忍。在JAVA層面計算大量資料確實不是明智的選擇,所以我又把這個方法移到了JNI去計算,效果非常顯著,執行這個方法最多隻需要兩秒鐘,原始碼已更新。
PSS:發現手機拍的視訊播放到TextureViewActivity介面的時候寬高比不對,又優化了下。首先想到的是呼叫mediaPlayer.getVideoWidth()和mediaPlayer.getVideoHeight()來對TextureView重新設定寬高,但失敗了,mediaPlayer一旦準備就緒後就沒辦法再修改TextureView的size,否則播放無影象。這時候又想到了MediaMetadataRetriever,不得不說這時候它還是很好用的:
/**
* dp轉換px
*/
public int dip2px(Context context, float dipValue) {
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dipValue, context.getResources()
.getDisplayMetrics());
}
private float videoWidth;
private float videoHeight;
private int videoRotation;
private void initVideoSize() {
MediaMetadataRetriever mmr = new MediaMetadataRetriever();
try {
mmr.setDataSource(mUrl);
String width = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH);
String height = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT);
String rotation = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION);
videoWidth = Float.valueOf(width);
videoHeight = Float.valueOf(height);
videoRotation = Integer.valueOf(rotation);
int w1;
if (videoRotation == 90) {
w1 = (int) ((videoHeight / videoWidth) * dip2px(TextureVideoActivity.this, 250));
} else {
w1 = (int) (videoWidth / videoHeight * dip2px(TextureVideoActivity.this, 250));
}
LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams) mPreview.getLayoutParams();
layoutParams.width = w1;
layoutParams.height = mPreview.getHeight();
mPreview.setLayoutParams(layoutParams);
} catch (Exception ex) {
Log.e(TAG, "MediaMetadataRetriever exception " + ex);
} finally {
mmr.release();
}
}
播放前先呼叫以上程式碼進行TextureView的寬高初始化,MediaMetadataRetriever可以獲取到視訊源的寬高和旋轉角度。手機拍攝的視訊,不論是橫著拍的還是豎著拍的,視訊源的寬高都是預設橫屏拍的寬高,所以必須要用到旋轉角度進行判斷。程式碼已更新到github上。