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一个。

注:后续为适配android10.0,targetSdkVersion升级到29,出现问题了,onSurfaceTextureAvailable不会执行导致画面没有了,解决方法在这里:TextureView有声音没画面&onSurfaceTextureAvailable没调用,不想看的也没关系,代码已更新。

    二、截图

    截图非常简单,只需要调用TextureView的getBitmap()方法就可以,连续快速地调用都没有问题。

    三、转换成gif

    这里用到了一个第三方开源项目GifBuilder(https://github.com/GLGJing/GIFBuilder),使用也很简单:

//另开线程并执行
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左右也就够了。

上效果图:

20180318184707805uploading.4e448015.gif转存失败重新上传取消

由25张分辨率440*247的bitmap合并而成,大小1.36M

angry.gifuploading.4e448015.gif转存失败重新上传取消angry.gifuploading.4e448015.gif转存失败重新上传取消angry.gifuploading.4e448015.gif转存失败重新上传取消发火直接由本地传的图片居然不动,有知道怎么传的请告诉我!!!

最后,上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上。

 
 
 

 


    

 

 

 

 

评论 7 您还未登录,请先 登录 后发表或查看评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
©️2022 CSDN 皮肤主题:编程工作室 设计师:CSDN官方博客 返回首页

打赏作者

qugengting

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值