一个Vue视频媒体多段裁剪组件的实现示例


Posted in Javascript onAugust 09, 2018

近日项目有个新需求,需要对视频或音频进行多段裁剪然后拼接。例如,一段视频长30分钟,我需要将5-10分钟、17-22分钟、24-29分钟这三段拼接到一起成一整段视频。裁剪在前端,拼接在后端。

网上简单找了找,基本都是客户端内的工具,没有纯网页的裁剪。既然没有,那就动手写一个。

代码已上传到GitHub: https://github.com/fengma1992/media-cut-tool

废话不多,下面就来看看怎么设计的。

效果图

一个Vue视频媒体多段裁剪组件的实现示例

图中底部的功能块为裁剪工具组件,上方的视频为演示用,当然也能是音频。

功能特点:

  1. 支持鼠标拖拽输入与键盘数字输入两种模式;
  2. 支持预览播放指定裁剪片段;
  3. 左侧鼠标输入与右侧键盘输入联动;
  4. 鼠标移动时自动捕捉高亮拖拽条;
  5. 确认裁剪时自动去重;

*注:项目中的图标都替换成了文字

思路

整体来看,通过一个数据数组 cropItemList 来保存用户输入数据,不管是鼠标拖拽还是键盘输入,都来操作 cropItemList 实现两侧数据联动。最后通过处理 cropItemList 来输出用户想要的裁剪。

cropItemList 结构如下:

cropItemList: [
 {
  startTime: 0, // 开始时间
  endTime: 100, // 结束时间
  startTimeArr: [hoursStr, minutesStr, secondsStr], // 时分秒字符串
  endTimeArr: [hoursStr, minutesStr, secondsStr], // 时分秒字符串
  startTimeIndicatorOffsetX: 0, // 开始时间在左侧拖动区X偏移量
  endTimeIndicatorOffsetX: 100, // 结束时间在左侧拖动区X偏移量
 }
]

第一步

既然是多段裁剪,那么用户得知道裁剪了哪些时间段,这通过右侧的裁剪列表来呈现。

列表

列表存在三个状态:

无数据状态

一个Vue视频媒体多段裁剪组件的实现示例

无数据的时候显示内容为空,当用户点击输入框时主动为他生成一条数据,默认为视频长度的1/4到3/4处。

有一条数据

一个Vue视频媒体多段裁剪组件的实现示例

此时界面显示很简单,将唯一一条数据呈现。

有多条数据

一个Vue视频媒体多段裁剪组件的实现示例

有多条数据时就得有额外处理了,因为第1条数据在最下方,而如果用 v-for 去循环 cropItemList,那么就会出现下图的状况:

一个Vue视频媒体多段裁剪组件的实现示例

而且,第1条最右侧是添加按钮,而剩下的最右侧都是删除按钮。所以,我们 将第1条单独提出来写,然后将 cropItemList 逆序生成一个 renderList 并循环 renderList0 -> listLength - 2

即可。

<template v-for="(item, index) in renderList">
 <div v-if="index < listLength -1"
   :key="index"
   class="crop-time-item">
   ...
   ...
 </div>
</template>

下图为最终效果:

一个Vue视频媒体多段裁剪组件的实现示例

时分秒输入

这个其实就是写三个 input 框,设 type="text" (设成 type=number 输入框右侧会有上下箭头),然后通过监听input事件来保证输入的正确性并更新数据。监听focus事件来确定是否需要在 cropItemList 为空时主动添加一条数据。

<div class="time-input">
 <input type="text"
  :value="renderList[listLength -1]
  && renderList[listLength -1].startTimeArr[0]"
  @input="startTimeChange($event, 0, 0)"
  @focus="inputFocus()"/>
 :
 <input type="text"
  :value="renderList[listLength -1]
  && renderList[listLength -1].startTimeArr[1]"
  @input="startTimeChange($event, 0, 1)"
  @focus="inputFocus()"/>
 :
 <input type="text"
  :value="renderList[listLength -1]
  && renderList[listLength -1].startTimeArr[2]"
  @input="startTimeChange($event, 0, 2)"
  @focus="inputFocus()"/>
</div>

播放片段

点击播放按钮时会通过 playingItem 记录当前播放的片段,然后向上层发出 play 事件并带上播放起始时间。同样还有 pausestop 事件,来控制媒体暂停与停止。

<CropTool :duration="duration"
   :playing="playing"
   :currentPlayingTime="currentTime"
   @play="playVideo"
   @pause="pauseVideo"
   @stop="stopVideo"/>
/**
 * 播放选中片段
 * @param index
 */
playSelectedClip: function (index) {
 if (!this.listLength) {
  console.log('无裁剪片段')
  return
 }
 this.playingItem = this.cropItemList[index]
 this.playingIndex = index
 this.isCropping = false
 
 this.$emit('play', this.playingItem.startTime || 0)
}

这里控制了开始播放,那么如何让媒体播到裁剪结束时间的时候自动停止呢?

监听媒体的 timeupdate 事件并实时对比媒体的 currentTimeplayingItemendTime ,达到的时候就发出 pause 事件通知媒体暂停。

if (currentTime >= playingItem.endTime) {
 this.pause()
}

至此,键盘输入的裁剪列表基本完成,下面介绍鼠标拖拽输入。

第二步

下面介绍如何通过鼠标点击与拖拽输入。

1、确定鼠标交互逻辑

新增裁剪

鼠标在拖拽区点击后,新增一条裁剪数据,开始时间与结束时间均为 mouseup 时进度条的时间,并让结束时间戳跟随鼠标移动,进入编辑状态。

确认时间戳

编辑状态,鼠标移动时,时间戳根据鼠标在进度条的当前位置来随动,鼠标再次点击后确认当前时间,并终止时间戳跟随鼠标移动。

更改时间

非编辑状态,鼠标在进度条上移动时,监听 mousemove 事件,在接近任意一条裁剪数据的开始或结束时间戳时高亮当前数据并显示时间戳。鼠标 mousedown 后选中时间戳并开始拖拽更改时间数据。 mouseup 后结束更改。

2、确定需要监听的鼠标事件

鼠标在进度条区域需要监听三个事件: mousedownmousemovemouseup 。 在进度条区存在多种元素,简单可分成三类:

  1. 鼠标移动时随动的时间戳
  2. 存在裁剪片段时的开始时间戳、结束时间戳、浅蓝色的时间遮罩
  3. 进度条本身

首先 mousedownmouseup 的监听当然是绑定在进度条本身。

this.timeLineContainer.addEventListener('mousedown', e => {
  const currentCursorOffsetX = e.clientX - containerLeft
  lastMouseDownOffsetX = currentCursorOffsetX
  // 检测是否点到了时间戳
  this.timeIndicatorCheck(currentCursorOffsetX, 'mousedown')
 })
 
this.timeLineContainer.addEventListener('mouseup', e => {

 // 已经处于裁剪状态时,鼠标抬起,则裁剪状态取消
 if (this.isCropping) {
  this.stopCropping()
  return
 }

 const currentCursorOffsetX = this.getFormattedOffsetX(e.clientX - containerLeft)
 // mousedown与mouseup位置不一致,则不认为是点击,直接返回
 if (Math.abs(currentCursorOffsetX - lastMouseDownOffsetX) > 3) {
  return
 }

 // 更新当前鼠标指向的时间
 this.currentCursorTime = currentCursorOffsetX * this.timeToPixelRatio

 // 鼠标点击新增裁剪片段
 if (!this.isCropping) {
  this.addNewCropItemInSlider()

  // 新操作位置为数组最后一位
  this.startCropping(this.cropItemList.length - 1)
 }
})

mousemove 这个,当非编辑状态时,当然是监听进度条来实现时间戳随动鼠标。而当需要选中开始或结束时间戳来进入编辑状态时,我最初设想的是监听时间戳本身,来达到选中时间戳的目的。而实际情况是:当鼠标接近开始或结束时间戳时,一直有一个鼠标随动的时间戳挡在前面,而且因为裁剪片段理论上可以无限增加,那我得监听2*裁剪片段个 mousemove

基于此,只在进度条本身监听 mousemove ,通过实时比对鼠标位置和时间戳位置来确定是否到了相应位置, 当然得加一个 throttle 节流。

this.timeLineContainer.addEventListener('mousemove', e => {
 throttle(() => {
  const currentCursorOffsetX = e.clientX - containerLeft
  // mousemove范围检测
  if (currentCursorOffsetX < 0 || currentCursorOffsetX > containerWidth) {
   this.isCursorIn = false
   // 鼠标拖拽状态到达边界直接触发mouseup状态
   if (this.isCropping) {
    this.stopCropping()
    this.timeIndicatorCheck(currentCursorOffsetX < 0 ? 0 : containerWidth, 'mouseup')
   }
   return
  }
  else {
   this.isCursorIn = true
  }

  this.currentCursorTime = currentCursorOffsetX * this.timeToPixelRatio
  this.currentCursorOffsetX = currentCursorOffsetX
  // 时间戳检测
  this.timeIndicatorCheck(currentCursorOffsetX, 'mousemove')
  // 时间戳移动检测
  this.timeIndicatorMove(currentCursorOffsetX)
 }, 10, true)()
})

3、实现拖拽与时间戳随动

下面是时间戳检测和时间戳移动检测代码

timeIndicatorCheck (currentCursorOffsetX, mouseEvent) {
 // 在裁剪状态,直接返回
 if (this.isCropping) {
  return
 }

 // 鼠标移动,重设hover状态
 this.startTimeIndicatorHoverIndex = -1
 this.endTimeIndicatorHoverIndex = -1
 this.startTimeIndicatorDraggingIndex = -1
 this.endTimeIndicatorDraggingIndex = -1
 this.cropItemHoverIndex = -1

 this.cropItemList.forEach((item, index) => {
  if (currentCursorOffsetX >= item.startTimeIndicatorOffsetX
   && currentCursorOffsetX <= item.endTimeIndicatorOffsetX) {
   this.cropItemHoverIndex = index
  }

  // 默认始末时间戳在一起时优先选中截止时间戳
  if (isCursorClose(item.endTimeIndicatorOffsetX, currentCursorOffsetX)) {
   this.endTimeIndicatorHoverIndex = index
   // 鼠标放下,开始裁剪
   if (mouseEvent === 'mousedown') {
    this.endTimeIndicatorDraggingIndex = index
    this.currentEditingIndex = index
    this.isCropping = true
   }
  }

  else if (isCursorClose(item.startTimeIndicatorOffsetX, currentCursorOffsetX)) {
   this.startTimeIndicatorHoverIndex = index
   // 鼠标放下,开始裁剪
   if (mouseEvent === 'mousedown') {
    this.startTimeIndicatorDraggingIndex = index
    this.currentEditingIndex = index
    this.isCropping = true
   }
  }
 })
},

timeIndicatorMove (currentCursorOffsetX) {
 // 裁剪状态,随动时间戳
 if (this.isCropping) {
  const currentEditingIndex = this.currentEditingIndex
  const startTimeIndicatorDraggingIndex = this.startTimeIndicatorDraggingIndex
  const endTimeIndicatorDraggingIndex = this.endTimeIndicatorDraggingIndex
  const currentCursorTime = this.currentCursorTime

  let currentItem = this.cropItemList[currentEditingIndex]
  // 操作起始位时间戳
  if (startTimeIndicatorDraggingIndex > -1 && currentItem) {
   // 已到截止位时间戳则直接返回
   if (currentCursorOffsetX > currentItem.endTimeIndicatorOffsetX) {
    return
   }
   currentItem.startTimeIndicatorOffsetX = currentCursorOffsetX
   currentItem.startTime = currentCursorTime
  }

  // 操作截止位时间戳
  if (endTimeIndicatorDraggingIndex > -1 && currentItem) {
   // 已到起始位时间戳则直接返回
   if (currentCursorOffsetX < currentItem.startTimeIndicatorOffsetX) {
    return
   }
   currentItem.endTimeIndicatorOffsetX = currentCursorOffsetX
   currentItem.endTime = currentCursorTime
  }
  this.updateCropItem(currentItem, currentEditingIndex)
 }
}

第三步

裁剪完成后下一步当然是把数据丢给后端啦。

把用户当:sweet_potato:(#红薯#)

用户使用的时候小手一抖,多点了一下 添加 按钮,或者有帕金森,怎么都拖不准,就可能会有数据一样或存在重合部分的裁剪片段。那么我们就得过滤掉重复和存在重合部分的裁剪。

还是直接看代码方便

/**
 * cropItemList排序并去重
 */
cleanCropItemList () {
 let cropItemList = this.cropItemList
 
 // 1. 依据startTime由小到大排序
 cropItemList = cropItemList.sort(function (item1, item2) {
  return item1.startTime - item2.startTime
 })

 let tempCropItemList = []
 let startTime = cropItemList[0].startTime
 let endTime = cropItemList[0].endTime
 const lastIndex = cropItemList.length - 1

 // 遍历,删除重复片段
 cropItemList.forEach((item, index) => {
  // 遍历到最后一项,直接写入
  if (lastIndex === index) {
   tempCropItemList.push({
    startTime: startTime,
    endTime: endTime,
    startTimeArr: formatTime.getFormatTimeArr(startTime),
    endTimeArr: formatTime.getFormatTimeArr(endTime),
   })
   return
  }
  // currentItem片段包含item
  if (item.endTime <= endTime && item.startTime >= startTime) {
   return
  }
  // currentItem片段与item有重叠
  if (item.startTime <= endTime && item.endTime >= endTime) {
   endTime = item.endTime
   return
  }
  // currentItem片段与item无重叠,向列表添加一项,更新记录参数
  if (item.startTime > endTime) {
   tempCropItemList.push({
    startTime: startTime,
    endTime: endTime,
    startTimeArr: formatTime.getFormatTimeArr(startTime),
    endTimeArr: formatTime.getFormatTimeArr(endTime),
   })
   // 标志量移到当前item
   startTime = item.startTime
   endTime = item.endTime
  }
 })

 return tempCropItemList
}

第四步

使用裁剪工具: 通过props及emit事件实现媒体与裁剪工具之间的通信。

<template>
 <div id="app">
  <video ref="video" src="https://pan.prprpr.me/?/dplayer/hikarunara.mp4"
  controls
  width="600px">
  </video>
  <CropTool :duration="duration"
     :playing="playing"
     :currentPlayingTime="currentTime"
     @play="playVideo"
     @pause="pauseVideo"
     @stop="stopVideo"/>
 </div>
</template>

<script>
 import CropTool from './components/CropTool.vue'
 
 export default {
  name: 'app',
  components: {
   CropTool,
  },
  data () {
   return {
    duration: 0,
    playing: false,
    currentTime: 0,
   }
  },
  mounted () {
   const videoElement = this.$refs.video
   videoElement.ondurationchange = () => {
    this.duration = videoElement.duration
   }
   videoElement.onplaying = () => {
    this.playing = true
   }
   videoElement.onpause = () => {
    this.playing = false
   }
   videoElement.ontimeupdate = () => {
    this.currentTime = videoElement.currentTime
   }
  },
  methods: {
   seekVideo (seekTime) {
    this.$refs.video.currentTime = seekTime
   },
   playVideo (time) {
    this.seekVideo(time)
    this.$refs.video.play()
   },
   pauseVideo () {
    this.$refs.video.pause()
   },
   stopVideo () {
    this.$refs.video.pause()
    this.$refs.video.currentTime = 0
   },
  },
 }
</script>

总结

写博客比写代码难多了,感觉很混乱的写完了这个博客。

几个小细节 列表增删时的高度动画

一个Vue视频媒体多段裁剪组件的实现示例

UI提了个需求,最多展示10条裁剪片段,超过了之后就滚动,还得有增删动画。本来以为直接设个 max-height 完事,结果发现

CSS的 transition 动画只有针对绝对值的height有效 ,这就有点小麻烦,因为裁剪条数是变化的,那么高度也是在变化的。设绝对值该怎么办呢。。。

这里通过HTML中tag的 attribute 属性 data-count 来告诉CSS我现在有几条裁剪,然后让CSS根据 data-count 来设置列表高度。

<!--超过10条数据也只传10,让列表滚动-->
<div 
 class="crop-time-body"
 :data-count="listLength > 10 ? 10 : listLength -1">
</div>
.crop-time-body {
 overflow-y: auto;
 overflow-x: hidden;
 transition: height .5s;

 &[data-count="0"] {
  height: 0;
 }

 &[data-count="1"] {
  height: 40px;
 }

 &[data-count="2"] {
  height: 80px;
 }

 ...
 ...

 &[data-count="10"] {
  height: 380px;
 }
}

mousemove 时事件的 currentTarget 问题

因为存在DOM事件的捕获与冒泡,而进度条上面可能有别的如时间戳、裁剪片段等元素, mousemove 事件的 currentTarget 可能会变,导致取鼠标距离进度条最左侧的 offsetX 可能有问题;而如果通过检测 currentTarget 是否为进度条也存在问题,因为鼠标移动的时候一直有个时间戳在随动,导致偶尔一段时间都触发不了进度条对应的 mousemove 事件。

解决办法就是,页面加载完成后取得进度条最左侧距页面最左侧的距离, mousemove 事件不取 offsetX ,转而取基于页面最左侧的 clientX ,然后两者相减就得到了鼠标距离进度条最左侧的像素值。代码在上文中的添加 mousemove 监听里已写。

时间格式化

因为裁剪工具很多地方需要将秒转换为 00:00:00 格式的字符串,因此写了一个工具函数:输入秒,输出一个包含 dd,HH,mm,ss 四个 keyObject ,每个 key 为长度为2的字符串。用ES8的 String.prototype.padStart() 方法实现。

export default function (seconds) {
 const date = new Date(seconds * 1000);
 return {
  days: String(date.getUTCDate() - 1).padStart(2, '0'),
  hours: String(date.getUTCHours()).padStart(2, '0'),
  minutes: String(date.getUTCMinutes()).padStart(2, '0'),
  seconds: String(date.getUTCSeconds()).padStart(2, '0')
 };

}

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
25个非常棒的jQuery滑块插件和教程小结
Sep 02 Javascript
javascript修改IMG标签的src问题
Mar 28 Javascript
javascript动态控制服务器控件实例
Sep 05 Javascript
js使用setTimeout实现定时炸弹的方法
Apr 10 Javascript
js验证上传图片的方法
May 12 Javascript
JavaScript中对DOM节点的访问、创建、修改、删除
Nov 16 Javascript
JavaScript中in和hasOwnProperty区别详解
Aug 04 Javascript
vue-router实现tab标签页(单页面)详解
Oct 17 Javascript
p5.js入门教程之小球动画示例代码
Mar 15 Javascript
a标签调用js的方法总结
Sep 05 Javascript
JavaScript数组去重实现方法小结
Jan 17 Javascript
小程序角标的添加及绑定购物车数量进行实时更新的实现代码
Dec 07 Javascript
bootstrap动态调用select下拉框的实例代码
Aug 09 #Javascript
vue.js 中使用(...)运算符报错的解决方法
Aug 09 #Javascript
select2 ajax 设置默认值,初始值的方法
Aug 09 #Javascript
bootstrap select2插件用ajax来获取和显示数据的实例
Aug 09 #Javascript
微信小程序url传参写变量的方法
Aug 09 #Javascript
angular6.x中ngTemplateOutlet指令的使用示例
Aug 09 #Javascript
koa上传excel文件并解析的实现方法
Aug 09 #Javascript
You might like
解析Ubuntu下crontab命令的用法
2013/06/24 PHP
php强大的时间转换函数strtotime
2016/02/18 PHP
php设计模式之迭代器模式实例分析【星际争霸游戏案例】
2020/04/07 PHP
一些常用弹出窗口/拖放/异步文件上传等实用代码
2013/01/06 Javascript
js将long日期格式转换为标准日期格式实现思路
2013/04/07 Javascript
javascript中直接引用Microsoft的COM生成Word
2014/01/20 Javascript
基于NodeJS的前后端分离的思考与实践(五)多终端适配
2014/09/26 NodeJs
JS扩展方法实例分析
2015/04/15 Javascript
JavaScript实现鼠标滑过处生成气泡的方法
2015/05/16 Javascript
Vue.js基础知识汇总
2016/04/27 Javascript
js友好的时间返回函数
2016/08/24 Javascript
[原创]JS基于FileSaver.js插件实现文件保存功能示例
2016/12/08 Javascript
原生JS实现左右箭头选择日期实例代码
2017/03/14 Javascript
使用Nodejs连接mongodb数据库的实现代码
2017/08/21 NodeJs
微信小程序 页面滑动事件的实例详解
2017/10/12 Javascript
浅谈gulp创建完整的项目流程
2017/12/20 Javascript
浅谈Vue2.0父子组件间事件派发机制
2018/01/08 Javascript
基于jQuery实现Ajax验证用户名是否可用实例
2018/03/25 jQuery
搭建vue开发环境
2018/07/19 Javascript
使用vue点击li,获取当前点击li父辈元素的属性值方法
2018/09/12 Javascript
jQuery中实现text()的方法
2019/04/04 jQuery
JS面向对象编程基础篇(一) 对象和构造函数实例详解
2020/03/03 Javascript
Vue 构造选项 - 进阶使用说明
2020/08/14 Javascript
Python 检查数组元素是否存在类似PHP isset()方法
2014/10/14 Python
pycharm打开命令行或Terminal的方法
2019/01/16 Python
python3 常见解密加密算法实例分析【base64、MD5等】
2019/12/19 Python
Python的形参和实参使用方式
2019/12/24 Python
北美领先的牛仔品牌:Buffalo David Bitton
2017/05/22 全球购物
介绍一下grep命令的使用
2015/06/12 面试题
后勤主管工作职责
2013/12/07 职场文书
超越自我演讲稿
2014/05/21 职场文书
客运企业隐患排查工作方案
2014/06/06 职场文书
故意伤害辩护词
2015/05/21 职场文书
pytorch中Schedule与warmup_steps的用法说明
2021/05/24 Python
js实现自动锁屏功能
2021/06/02 Javascript
golang连接MySQl使用sqlx库
2022/04/14 Golang