1. 程式人生 > >android視訊播放截圖並製作成gif圖片

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上。