用canvas实现逐帧预览视频,并下载

HTML

html 复制代码
<div>
    <input type="file">
</div>
<div>
    <button class="download">下载</button>
</div>
<video></video>
<div class="preview">
    <span class="slider"></span>
    <div class="picArea"></div>
</div>
css 复制代码
.preview {
    position: relative;
    overflow: hidden;
}

.slider {
    position: absolute;
    left: 0;
    top: 0;
    background-color: rgba(255, 255, 255, .8);
    width: 15px;
    height: 100%;
    cursor: pointer;
    z-index: 99;
}

具体实现

初始化

js 复制代码
 // DOM
const inp = document.querySelector('input'),
    preview = document.querySelector('.preview'), // 预览小图区域
    slider = document.querySelector('.slider'),   // 滑块
    picArea = preview.querySelector('.picArea'),        // 每张小图容器
    video = document.querySelector('video'),
    download = document.querySelector('.download')

// 尺寸
const sliderWidth = getStyle(slider, 'width')

// 每张预览图信息
let picArr = [],
    isFirst = true,
    sliderX = 0,        // 鼠标移动滑块坐标
    perPicsWidth = 0,   // 每张小图大小
    selectedIndex = 0,  // 选中的预览图数组索引
    picsWidth = 0,      // 预览小图容器宽度
    maxPicsOffsetLeft = 0   // 预览小图最大偏移值 

// 配置
let VIDEO_HEIGHT = 0
const VIDEO_WIDTH = 500,
    RATIO = 5  // 缩放比例

获取具体帧画面

js 复制代码
/**
 * 生成视频某秒图片 大于总时长则用最后一秒
 * @param {File} file 
 * @param {number | number[]} timeOrArray 
 */
async function captureFrame(file, timeOrArray) {
    if (typeof timeOrArray === 'number') {
        return await genFrame(timeOrArray)
    }
    else {
        const arr = []
        timeOrArray.forEach((t) => {
            arr.push(genFrame(t))
        })
        return Promise.all(arr)
    }
}

这个函数,能根据给定的视频文件和某一秒,生成那一秒的视频画面

时间也可以给个数组,最终返回对应的Bloburl对象

接下来需要实现genFrame函数

js 复制代码
// 生成指定秒画面
async function genFrame(time) {
    const vdo = document.createElement('video'),
        src = url = URL.createObjectURL(file)

    vdo.currentTime = time
    vdo.muted = true
    vdo.autoplay = true
    vdo.src = src

    return new Promise((resolve, reject) => {
        vdo.oncanplay = () => {
            resolve(videoToCanvas(vdo))
        }
        vdo.onerror = (err) => {
            reject(err)
        }
    })
}

首先在生成一个video元素,把文件源转成临时的url,并赋值

然后设置在视频第几秒和静音,这样视频才能播放,再开启自动播放

由于视频没有加入页面,所以只会停留在那一秒

然后再视频加载完成的事件oncanplay里,把这一画面放入canvas

js 复制代码
 /**
 * 根据视频文件 生成对应时间的封面
 */
function videoToCanvas(vdo) {
    const cvs = document.createElement('canvas'),
        ctx = cvs.getContext('2d'),
        { videoWidth, videoHeight } = vdo,
        w = VIDEO_WIDTH / RATIO,
        h = VIDEO_HEIGHT / RATIO

    // 每张小图宽度
    perPicsWidth = w
    cvs.height = h
    cvs.width = w

    // 生成预览小图
    ctx.drawImage(vdo, 0, 0, w, h)
    picArea.appendChild(cvs)
    // 预览图高度设置一致
    picArea.style.height = h + 'px'

    // 存入原图
    const oriCvs = document.createElement('canvas'),
        orictx = oriCvs.getContext('2d')

    oriCvs.height = vdo.videoHeight
    oriCvs.width = vdo.videoWidth
    orictx.drawImage(vdo, 0, 0)
    return new Promise((resolve) => {
        oriCvs.toBlob(blob => resolve({
            blob,
            url: URL.createObjectURL(blob)
        }))
    })
}

由于视频一般都很大,比如1920 * 1080,这样就全屏了,所以我把canvas等比例缩小了,再放入容器

这个仅仅作为预览图,如果要下载的话,需要原始大小,不然很模糊

oriCvs就是原始大小

最终画入canvas上下文,转成Blob和对应的url,返回一个Promise对象即可

然后绑定一下input的选择文件事件

js 复制代码
// 给视频添加选择的文件 并生成预览条
inp.onchange = function () {
    const file = this.files[0]

    video.src = URL.createObjectURL(file)
    video.oncanplay = async () => {
        video.style.width = VIDEO_WIDTH + 'px'
        // 宽 / 原始宽 = 比例;   比例 * 原始高度 = 最终高度
        VIDEO_HEIGHT = VIDEO_WIDTH / video.videoWidth * video.videoHeight

        if (isFirst) {
            isFirst = false
            picArr = await captureFrame(file, [1, 2, 3, 4, 5, 6, 7])

            // 设置小图容器宽度
            picsWidth = picArr.length * VIDEO_WIDTH / RATIO
            picArea.style.width = picsWidth + 'px'

            maxPicsOffsetLeft = picsWidth - VIDEO_WIDTH
        }
    }
}

这里的captureFrame,我生成了前7秒的画面

因为后续需要多次改动视频时间,会触发oncanplay事件,导致频繁生成预览图,所以加个isFirst条件判断

然后选择文件,就生成预览图和视频了

接着给预览区域滑块绑定事件

js 复制代码
slider.addEventListener('mousedown', (e) => {
    setSliderPos(e)
    window.addEventListener('mousemove', onMouseMove)
    window.addEventListener('mouseup', onMouseUp)
})

这里用事件委托,让用户交互体验更好,绑定在window可以在任意地方滑动

js 复制代码
 // 事件函数
function onMouseMove(e) {
   setSliderPos(e)

    selectedIndex = Math.floor(sliderX / perPicsWidth)
    if (selectedIndex >= picArr.length - 1) {
        selectedIndex = picArr.length - 1
    }
    else if (selectedIndex === -0 || selectedIndex <= 0) {
        selectedIndex = 0
    }
    // 视频从第一秒开始 索引从0开始
    video.currentTime = selectedIndex + 1
}

function onMouseUp() {
    window.removeEventListener('mousemove', onMouseMove)
    window.removeEventListener('mouseup', onMouseUp)
}

鼠标移动时,记录位置,注意算出来的值可能是-0或者超出范围

需要判断一下

鼠标抬起时,全部解绑

移动时设置位置和视频的时间

这个setSliderPos是重点

js 复制代码
function setSliderPos(e) {
    const { left } = preview.getBoundingClientRect()
    sliderX = e.clientX - left
    const x = sliderX - sliderWidth / 2   // 居中
    slider.style.transform = `translateX(${x}px)`

用鼠标位置减去整块预览区域,得到偏移值

然后减去一半居中,再进行移动

这样就初步实现了功能

但是移动位置没有限制

而且要是生成预览图太多,后面的就看不见了,所以预览区域需要在特定时机移动

那么什么时候移动呢,当滑块超过一半时移动最佳,并设置最大偏移值

完整做法如下

js 复制代码
 function setSliderPos(e) {
    const { left } = preview.getBoundingClientRect()
    sliderX = e.clientX - left
    const x = sliderX - sliderWidth / 2   // 居中

    if (!canMove()) return
    movePicArea()

    slider.style.transform = `translateX(${x}px)`


    function canMove() {
        if (x > 0 && x < VIDEO_WIDTH - sliderWidth) {
            return true
        }
    }

    function movePicArea() {
        const threshold = VIDEO_WIDTH / 2
        if (x > threshold) {
            let offsetLeft = x - threshold
            offsetLeft >= maxPicsOffsetLeft && (offsetLeft = maxPicsOffsetLeft)
            picArea.style.transform = `translateX(${-offsetLeft}px)`
        }
    }
}

这样能用吗,如果不仔细测试的话,看起来很完美

但是这里picArea也移动了,所以他们的坐标会出问题,导致后面加载的画面不一致

这时怎么办呢??

那现在就需要一个能相对于整个容器的坐标,所以要额外加个标签

html 复制代码
<div class="preview">
    <span class="slider"></span>
    <div class="picArea">
        <div class="get-offset-area"></div>
    </div>
</div>

.get-offset-area {
    position: absolute;
    inset: 0;
}

这个get-offset-area会铺满整个容器,然后给他绑定事件,就能获取他的offsetX

也就是相对于picArea里的真正坐标

但是现在你绑定事件他能触发吗?

答案是不能

为什么呢?

因为你需要移动滑块,而他在滑块下面

所以需要把mousemove事件改成mouseover,让他冒泡上去

但这样移动就没那么丝滑了,因为mouseover触发的没有那么频繁

js 复制代码
 slider.addEventListener('mousedown', (e) => {
    e.preventDefault()  // 防止鼠标移不动
    setSliderPos(e)

    // 使用`mouseover`是因为需要冒泡到`getOffsetArea` 获取他的相对坐标
    window.addEventListener('mouseover', onMouseOver)
    window.addEventListener('mouseup', onMouseUp)
    getOffsetArea.addEventListener('mouseover', onGetOffsetAreaMouseOver)
})

注意!!这里mousedown的默认行为一定要阻止,不然鼠标样式会被改

现在把真实的值赋值即可

js 复制代码
function onGetOffsetAreaMouseOver(e) {
    realSilderX = e.offsetX
}

在改一下计算索引的值

js 复制代码
function onMouseOver(e) {
    setSliderPos(e)

    selectedIndex = Math.floor(realSilderX / perPicsWidth)
    if (selectedIndex >= picArr.length - 1) {
        selectedIndex = picArr.length - 1
    }
    else if (selectedIndex === -0 || selectedIndex <= 0) {
        selectedIndex = 0
    }
    // 视频从第一秒开始 索引从0开始
    video.currentTime = selectedIndex + 1
}

再加个下载事件就可以了

js 复制代码
download.onclick = function () {
    const url = picArr[selectedIndex].url,
        a = document.createElement('a')

    a.href = url
    a.download = Date.now()
    a.click()
}

源码 gitee.com/cjl2385/dig...

相关推荐
旺旺大力包6 分钟前
【 Git 】git 的安装和使用
前端·笔记·git
雪落满地香22 分钟前
前端:改变鼠标点击物体的颜色
前端
从以前37 分钟前
【算法题解】Bindian 山丘信号问题(E. Bindian Signaling)
开发语言·python·算法
不白兰41 分钟前
[代码随想录23回溯]回溯的组合问题+分割子串
算法
余生H1 小时前
前端Python应用指南(二)深入Flask:理解Flask的应用结构与模块化设计
前端·后端·python·flask·全栈
outstanding木槿1 小时前
JS中for循环里的ajax请求不数据
前端·javascript·react.js·ajax
酥饼~1 小时前
html固定头和第一列简单例子
前端·javascript·html
一只不会编程的猫1 小时前
高德地图自定义折线矢量图形
前端·vue.js·vue
m0_748250931 小时前
html 通用错误页面
前端·html
来吧~1 小时前
vue3使用video-player实现视频播放(可拖动视频窗口、调整大小)
前端·vue.js·音视频