一个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 相关文章推荐
Javascript实例教程(19) 使用HoTMetal(2)
Dec 23 Javascript
JS鼠标事件大全 推荐收藏
Nov 01 Javascript
js比较和逻辑运算符的介绍
Mar 10 Javascript
JS实现跟随鼠标闪烁转动色块的方法
Feb 26 Javascript
jQuery选择器源码解读(四):tokenize方法的Expr.preFilter
Mar 31 Javascript
JavaScript实现把rgb颜色转换成16进制颜色的方法
Jun 01 Javascript
js+html5实现canvas绘制简单矩形的方法
Jun 05 Javascript
javascript:void(0)点击登录没反应怎么解决
Nov 13 Javascript
React Native实现简单的登录功能(推荐)
Sep 19 Javascript
详解Angular 4.x NgTemplateOutlet
May 24 Javascript
jQuery each和js forEach用法比较
Feb 27 jQuery
vue 通过 Prop 向子组件传递数据的实现方法
Oct 30 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+ajax实时输入自动搜索匹配的方法
2014/12/26 PHP
php两种无限分类方法实例
2015/04/21 PHP
修改Laravel5.3中的路由文件与路径
2016/08/10 PHP
PHPExcel导出2003和2007的excel文档功能示例
2017/01/04 PHP
PHP中Trait及其应用详解
2017/02/14 PHP
常见JS效果之图片减速度滚动实现代码
2011/12/08 Javascript
jQuery的显示和隐藏方法与css隐藏的样式对比
2013/10/18 Javascript
jquery 字符串切割函数substring的用法说明
2014/02/11 Javascript
jQuery基于当前元素进行下一步的遍历
2014/05/20 Javascript
JS实现判断碰撞的方法
2015/02/11 Javascript
深入探讨JavaScript String对象
2015/03/09 Javascript
js+css实现的圆角边框TAB选项卡滑动门代码分享(2款)
2015/08/26 Javascript
AngularJs页面筛选标签小功能
2016/08/01 Javascript
js实现键盘自动打字效果
2016/12/23 Javascript
Javascript中JSON数据分组优化实践及JS操作JSON总结
2017/12/22 Javascript
vue项目中jsonp跨域获取qq音乐首页推荐问题
2018/05/30 Javascript
Vue项目总结之webpack常规打包优化方案
2019/06/06 Javascript
Python入门篇之列表和元组
2014/10/17 Python
Python中实现对Timestamp和Datetime及UTC时间之间的转换
2015/04/08 Python
Python实现的计数排序算法示例
2017/11/29 Python
python使用正则表达式来获取文件名的前缀方法
2018/10/21 Python
python使用matplotlib绘制热图
2018/11/07 Python
python输出带颜色字体实例方法
2019/09/01 Python
通过代码实例解析Pytest运行流程
2020/08/20 Python
纯CSS3实现3D旋转书本效果
2016/03/21 HTML / CSS
html5本地存储之localstorage 、本地数据库、sessionStorage简单使用示例
2014/05/08 HTML / CSS
阿根廷在线宠物商店:Puppis
2018/03/23 全球购物
英国最大的在线床超市:Bed Star
2019/01/24 全球购物
文秘专业应届生求职信范文
2013/11/14 职场文书
一封普通求职者的求职信
2013/11/20 职场文书
信息管理员岗位职责
2013/12/01 职场文书
机关门卫制度
2014/02/01 职场文书
作文评语集锦大全
2014/04/23 职场文书
酒店节能减排方案
2014/05/26 职场文书
甘南现象心得体会
2014/09/11 职场文书
申报材料格式
2014/12/30 职场文书