一个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 Tooltip插件推荐
May 25 Javascript
JavaScript NaN和Infinity特殊值 [译]
Sep 20 Javascript
js日期对象兼容性的处理方法
Jan 28 Javascript
javascript从作用域链谈闭包
Jul 29 Javascript
详解Wondows下Node.js使用MongoDB的环境配置
Mar 01 Javascript
jquery简单插件制作(fn.extend)完整实例
May 24 Javascript
javascript日期比较方法实例分析
Jun 17 Javascript
jQuery查找节点并获取节点属性的方法
Sep 09 Javascript
JavaScript简单实现关键字文本搜索高亮显示功能示例
Jul 25 Javascript
Layui动态生成select下拉选择框不显示的解决方法
Sep 24 Javascript
element-ui中按需引入的实现
Dec 25 Javascript
ES6扩展运算符和rest运算符用法实例分析
May 23 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
php a simple smtp class
2007/11/26 PHP
php学习之数据类型之间的转换代码
2011/05/29 PHP
php实现xml与json之间的相互转换功能实例
2016/07/07 PHP
php+ajax注册实时验证功能
2016/07/20 PHP
tp5框架内使用tp3.2分页的方法分析
2019/05/05 PHP
php实现简单四则运算器
2020/11/29 PHP
php的对象传值与引用传值代码实例讲解
2021/02/26 PHP
javascript函数库-集合框架
2007/04/27 Javascript
jQuery 图片切换插件(代码比较少)
2012/05/07 Javascript
js限制文本框只能输入数字(正则表达式)
2012/07/15 Javascript
Js-$.extend扩展方法使方法参数更灵活
2013/01/15 Javascript
Javascript alert消息换行的方法
2013/08/07 Javascript
将json当数据库一样操作的javascript lib
2013/10/28 Javascript
JavaScript实现删除,移动和复制文件的方法
2015/08/05 Javascript
JS代码实现table数据分页效果
2016/05/26 Javascript
vue实现文章内容过长点击阅读全文功能的实例
2017/12/28 Javascript
详解JavaScript基础知识(JSON、Function对象、原型、引用类型)
2018/01/16 Javascript
解决vuecli3.0热更新失效的问题
2018/09/19 Javascript
vue router 组件的高级应用实例代码
2019/04/08 Javascript
layui-table获得当前行的上/下一行数据的例子
2019/09/24 Javascript
Vue调用后端java接口的实例代码
2019/10/28 Javascript
vue项目,代码提交至码云,iconfont的用法说明
2020/07/30 Javascript
Vue项目利用axios请求接口下载excel
2020/11/17 Vue.js
Vue 实例中使用$refs的注意事项
2021/01/29 Vue.js
Python根据区号生成手机号码的方法
2015/07/08 Python
Python中super()函数简介及用法分享
2016/07/11 Python
国际领先的学术出版商:Springer
2017/01/11 全球购物
西班牙Polo衫品牌:Polo Club
2020/08/09 全球购物
上海天奕面试题笔试题
2015/04/19 面试题
中专毕业生自我鉴定
2014/02/02 职场文书
社区植树节活动总结
2015/02/06 职场文书
python 如何在 Matplotlib 中绘制垂直线
2021/04/02 Python
如何将JavaScript将数组转为树形结构
2021/06/02 Javascript
如何利用opencv判断两张图片是否相同详解
2021/07/07 Python
JavaScript展开运算符和剩余运算符的区别详解
2022/02/18 Javascript
python实现简单的三子棋游戏
2022/04/28 Python