Vue + Electron 实现截屏功能

5,776 阅读5分钟

前言

之前做心电图桌面应用时实现了应用内截屏的功能,涉及到canvas,动画,文件下载等知识点,在此做一总结,本文章的方法只在 win 10,win 7 测试过。项目使用的vue版本为 2.6.10,vue-cli版本为 3.12.1,node版本为 v14.17.5,electron 版本 11.0.0。

※注:本文代码区域每行开头的“+”表示新增,“-”表示删除,“M”表示修改;代码中的“...”表示省略。

1 前置知识

1.1 Canvas API:CanvasRenderingContext2D.drawImage()

drawImage 可以将图像文件写入画布,做法是读取图片后,使用drawImage()方法将这张图片放上画布。

CanvasRenderingContext2D.drawImage()有三种使用格式。

ctx.drawImage(image, dx, dy);
ctx.drawImage(image, dx, dy, dWidth, dHeight);
ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

参数的含义如下:

  • image:图像元素,具体类型参考文档:developer.mozilla.org/zh-CN/docs/…
  • sx:图像内部的横坐标,用于映射到画布的放置点上。
  • sy:图像内部的纵坐标,用于映射到画布的放置点上。
  • sWidth:图像在画布上的宽度,会产生缩放效果。如果未指定,则图像不会缩放,按照实际大小占据画布的宽度。
  • sHeight:图像在画布上的高度,会产生缩放效果。如果未指定,则图像不会缩放,按照实际大小占据画布的高度。
  • dx:画布内部的横坐标,用于放置图像的左上角
  • dy:画布内部的纵坐标,用于放置图像的右上角
  • dWidth:图像在画布内部的宽度,会产生缩放效果。
  • dHeight:图像在画布内部的高度,会产生缩放效果。

下面是最简单的使用场景,将图像放在画布上,两者左上角对齐。

var canvas = document.getElementById('myCanvas');
var ctx = canvas.getContext('2d');

var img = new Image();
img.src = 'image.png';
img.onload = function () {
  ctx.drawImage(img, 0, 0);
};

上面代码将一个 PNG 图像放入画布。这时,图像将是原始大小,如果画布小于图像,就会只显示出图像左上角,正好等于画布大小的那一块。

如果要显示完整的图片,可以用图像的宽和高,设置成画布的宽和高。

var canvas = document.getElementById('myCanvas');
var ctx = canvas.getContext('2d');

var image = new Image(60, 45);
image.onload = drawImageActualSize;
image.src = 'https://example.com/image.jpg';

function drawImageActualSize() {
  canvas.width = this.naturalWidth;
  canvas.height = this.naturalHeight;
  ctx.drawImage(this, 0, 0, this.naturalWidth, this.naturalHeight);
}

上面代码中,<canvas>元素的大小设置成图像的本来大小,就能保证完整展示图像。由于图像的本来大小,只有图像加载成功以后才能拿到,因此调整画布的大小,必须放在image.onload这个监听函数里面。

此节摘取自阮一峰大神的文档:wangdoc.com/webapi/canv…

1.2 其他

其余vue、electron的知识点请自行学习

2 如何实现

2.1 截屏效果展示

截屏完成后可以将 截屏的图片保存到本地。

electron.gif

2.2 实现思路:

  • electron 主进程中的模块 desktopCapturer 配合 navigator.mediaDevices.getUserMedia API ,可以访问那些用于从桌面上捕获音频和视频的媒体源信息。从而捕获桌面获得媒体流数据 stream

@/utils/captureScreen.js 文件中:

const { remote, desktopCapturer } = require('electron')
// id: 数字-与显示相关联的唯一的标志符
// size <Object>: 屏幕尺寸, 含width、height属性
const { id, size } = remote.screen.getPrimaryDisplay()
const dialog = remote.dialog
const fs = require('fs')

const captureScreen = cb => {
    // darwin: 苹果; inux: linux; win32: windows
    if (process.platform === 'win32') {
        //老坑:desktopCapture => linux下无效
        desktopCapturer
            .getSources({
                // types: 列出要捕获的桌面源类型的字符串数组, 可用类型为 screen 和 window
                types: ['screen'],
                // thumbnailSize: 媒体源缩略图应缩放到的尺寸大小。 默认是 150 x 150。 当您不需要缩略图时,设置宽度或高度为0。 这将节省用于获取每个窗口和屏幕内容时的处理时间
                thumbnailSize: { width: 0, height: 0 },
            })
            // sources <DesktopCapturerSource[]>: 使用一个 DesktopCapturerSource 对象数组进行解析,每个 DesktopCapturerSource 表示可以捕获的一个屏幕或一个单独的窗口
            // source.display_id <String> :  一个由 Screen API 返回的与 Display 的 id 对应匹配的唯一标识符。 在某些平台上,这相当于上面 id 字段中的 XX 部分,其他平台则有所不同。 它在不可用时将会是一个空字符串
            .then(async sources => {
                for (let source of sources) {
                    if (parseInt(source.display_id) === id) {
                        try {
                            const stream = await navigator.mediaDevices.getUserMedia(
                                {
                                    audio: false,
                                    video: {
                                        mandatory: {
                                            chromeMediaSource: 'desktop',
                                            chromeMediaSourceId: source.id,
                                            minWidth: size.width,
                                            maxWidth: size.width,
                                            minHeight: size.height,
                                            maxHeight: size.height,
                                        },
                                    },
                                }
                            )
                            // console.log(stream);
                            handleStream(stream, cb)
                        } catch (error) {
                            console.log(error)
                        }
                    }
                }
            })
    } else {
        //linux
        navigator.mediaDevices
            .getUserMedia({
                audio: false,
                video: {
                    mandatory: {
                        chromeMediaSource: 'desktop',
                        // chromeMediaSourceId: source.id, //出现NotReadableError,是因为getPrimaryDisplay()返回的id不一致,不做多屏幕直接去掉就可以了
                        minWidth: size.width,
                        maxWidth: size.width,
                        minHeight: size.height,
                        maxHeight: size.height,
                    },
                },
            })
            .then(stream => handleStream(stream, cb))
    }
}
...
  • 将媒体流数据 stream 赋值给 video 的 srcObject 属性从而可以播放媒体文件。

  • 显示截图:通过 CanvasRenderingContext2D.drawImage() 方法将视频流图像文件写入到画布中以显示截取的屏幕。

  • 下载截图:将canvas元素转化为base 64格式的数据,然后通过 nodejs 中的 Buffer 模块转化为 buffer,再通过 electron 中的 dialog.showSaveDialog 及 nodejs 中的 fs.writeFile 下载即可。

@/utils/captureScreen.js 文件中:

...
let screenShootBlob
const handleStream = (stream, cb) => {
    let video = document.getElementById('video')
    // video.srcObject属性对应的媒体文件资源,可能是MediaStream、MediaSource、Blob或File对象。直接指定这个属性,就可以播放媒体文件
    video.srcObject = stream
    // 媒体文件元数据加载成功时触发
    video.onloadedmetadata = () => {
        video.play()
        // createSaveImageCanvas(video)
        let showScreenShootCanvas = document.getElementById('desktop_canvas')

        showScreenShootCanvas.width = size.width
        showScreenShootCanvas.height = size.height
        showScreenShootCanvas.style.width = size.width + 'px'
        showScreenShootCanvas.style.height = size.height + 'px'

        const ctx = showScreenShootCanvas.getContext('2d')
        // 用于擦除指定矩形区域的像素颜色,等同于把早先的绘制效果都去除    
        ctx.clearRect(0, 0, size.width, size.height)

        //转为bitmap,可以提高性能,降低canvas渲染延迟
        createImageBitmap(video).then(bmp => {
            ctx.drawImage(
                bmp,
                0,
                0,
                size.width,
                size.height,
                0,
                0,
                size.width,
                size.height
            )
            
            // 将 Canvas 数据转为 Data URI 格式的图像
            let base64Data = showScreenShootCanvas.toDataURL('image/png')
            let data = base64Data.split('base64,')[1]

            // 创建包含 string 的新 Buffer。 encoding 参数即第二个参数标识将 string 转换为字节时要使用的字符编码,注意 new Buffer(data, 'base64') 已弃用
            screenShootBlob =  Buffer.from(data, 'base64')        
            // 1080,558 是截图后 对话框显示图片区域的宽高        
            ctx.drawImage(bmp, 0, 0, size.width, size.height, 0, 0, 1080, 558)

            stream.getTracks()[0].stop() //关闭视频流,序号是反向的,此处只有一个所以是0

            cb && cb()
        })
    }
}

const saveScreenShoot = () => {
    dialog
        .showSaveDialog({
            title: '保存图片',
            defaultPath: `${+new Date()}.png`,
            filters: [ { name: 'Images', extensions: ['jpg', 'png'] },],
        })
        .then(res => {
            // console.log(res, screenShootBlob)
            if (res.filePath) {
                fs.writeFile(res.filePath, screenShootBlob, 'binary', err => {
                    if (err) {
                        console.log(err)
                    } else {
                        console.log('保存成功')
                    }
                })
            }
        })
}

export { captureScreen, saveScreenShoot }

2.3 vue组件中使用

  • 组件中html代码
    
       ...
<template>	
  <div >
       <!-- 截屏弹框 -->
        <div class="dialog_mask" v-show="isShowMask"></div>
        <div class="dialog_mask_start" v-show="isShowMaskStart"></div>
        <div class="dialog_screen_shoot" v-show="isShowScreenShoot">
            <div class="title">快照预览</div>
            <div class="screen_shoot_wrapper">
                <!-- <canvas class="canvas" id="save_image_canvas">
                    您的浏览器不支持 Canvas
                </canvas> -->
                <canvas class="canvas" id="desktop_canvas">
                    您的浏览器不支持 Canvas
                </canvas>
            </div>
            <div class="footer">
                <el-button
                    class="custom_button"
                    type="primary"
                    @click="handleSaveClick"
                    >保存</el-button
                >
                <el-button
                    class="custom_button"
                    type="primary"
                    @click="cancelScreenshoot"
                    >取消</el-button
                >
            </div>
        </div>

        <video id="video"></video>
  </div>
</template>	
  • 组件中的 js 代码
   ...
   import { captureScreen, saveScreenShoot } from '@/utils/captureScreen.js'
   ...
   
   // 截取屏幕代码:
   this.isShowMaskStart = true

                // 会先捕获屏幕,再执行动画
                captureScreen(() => {
                    this.isShowMaskStart = true

                    setTimeout(() => {
                        // console.log(mask);
                        let mask = document.querySelector('.dialog_mask_start')
                        mask.classList.add('screen_shoot_last')
                        // 动画结束后移除动画遮罩,显示截图后的 dialog
                        mask.addEventListener('transitionend', () => {
                            // console.log(this.isShowMaskStart);
                            this.isShowMaskStart = false
                            mask.classList.remove('screen_shoot_last')
                            this.isShowScreenShoot = true
                        })
                    }, 0)

                })
   
	// 保存图片代码: 
	调用 saveScreenShoot() 即可

  • 组件中 sass 代码
// 截屏遮罩层
.dialog_mask {
    position: fixed;
    width: 100%;
    height: 100%;
    left: 0;
    top: 0;
    background-color: rgba(0, 0, 0, 0.5);
    z-index: 777;
}

// 截屏动画开始的样式
.dialog_mask_start {
    position: fixed;
    width: 100%;
    height: 100%;
    left: 0;
    top: 0;
    z-index: 888;
    background-color: rgba(255, 255, 255, 0.1);
    transition: width 0.5s, height 0.5s;
    transform: translate3d(0, 0, 0);
    // 截屏动画结束的样式
    &.screen_shoot_last {
        border-radius: 10px;
        width: 1080px;
        height: 668px;
        left: 50%;
        top: 50%;
        transform: translate3d(-50%, -50%, 0);
    }
}

.dialog_screen_shoot {
    z-index: 999;
    position: absolute;
    width: 1080px;
    height: 668px;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    border-radius: 10px;
    overflow: hidden;

    .title {
        width: 100%;
        height: 40px;
        background-color: $white;
        display: flex;
        align-items: center;
        justify-content: center;
    }
    .screen_shoot_wrapper {
        width: 100%;
        height: 558px;
        background-color: $black;
        overflow: hidden;
        position: relative;

        #save_image_canvas {
            position: absolute;
            z-index: -999;
        }

    }
    .footer {
        height: 70px;
        background: #eee;
        padding: 0 30px;
        display: flex;
        align-items: center;
        justify-content: flex-end;
    }
}

#video {
    position: absolute;
    top: 100%;
}

3 碰到的坑

3.1 win 7 系统中获取到的 source.display_id 为 ""

最近在 win 7 系统测试时发现截屏功能出现了bug, 经过排查发现是获取到的 source.display_id 为 "",从而走不到 if (parseInt(source.display_id) === id) 中导致截屏失败。

解决方案:

    .then(async (sources) => {
                // console.log(sources)
                // win 7 中 source.display_id 为 '',为了兼容, 这里就不做判断了, 且不做多屏幕 
                // chromeMediaSourceId 也不需要了

                try {
                    const stream = await navigator.mediaDevices.getUserMedia({
                        audio: false,
                        video: {
                            mandatory: {
                                chromeMediaSource: 'desktop',
                                // chromeMediaSourceId: source.id,  
                                minWidth: size.width,
                                maxWidth: size.width,
                                minHeight: size.height,
                                maxHeight: size.height,
                            },
                        },
                    })

                    handleStream(stream, cb, size)
                } catch (error) {
                    console.log(error)
                }

            })