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

msedge_r8K7ahAk7h.gif

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>
.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;
}

具体实现

初始化

 // 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  // 缩放比例

获取具体帧画面

/**
 * 生成视频某秒图片 大于总时长则用最后一秒
 * @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函数

// 生成指定秒画面
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

 /**
 * 根据视频文件 生成对应时间的封面
 */
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的选择文件事件

// 给视频添加选择的文件 并生成预览条
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条件判断

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

image.png

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

slider.addEventListener('mousedown', (e) => {
    setSliderPos(e)

    window.addEventListener('mousemove', onMouseMove)
    window.addEventListener('mouseup', onMouseUp)
})

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

 // 事件函数
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是重点

function setSliderPos(e) {
    const { left } = preview.getBoundingClientRect()

    sliderX = e.clientX - left

    const x = sliderX - sliderWidth / 2   // 居中

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

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

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

这样就初步实现了功能

但是移动位置没有限制

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

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

完整做法如下

 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也移动了,所以他们的坐标会出问题,导致后面加载的画面不一致

这时怎么办呢??

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

<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触发的没有那么频繁

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




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

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

现在把真实的值赋值即可

function onGetOffsetAreaMouseOver(e) {
    realSilderX = e.offsetX
}

在改一下计算索引的值

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
}

再加个下载事件就可以了

download.onclick = function () {
    const url = picArr[selectedIndex].url,
        a = document.createElement('a')




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

msedge_r8K7ahAk7h.gif

源码
gitee.com/cjl2385/dig…

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYXyuvYf' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片