一个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 相关文章推荐
Dom 结点创建 基础知识
Oct 01 Javascript
左侧是表头的JS表格控件(自写,网上没有的)
Jun 04 Javascript
关于JS数组追加数组采用push.apply的问题
Jun 09 Javascript
简述JavaScript对传统文档对象模型的支持
Jun 16 Javascript
使用AngularJS编写较为优美的JavaScript代码指南
Jun 19 Javascript
jQuery实现模仿微博下拉滚动条加载数据效果
Dec 25 Javascript
原生js实现下拉框功能(支持键盘事件)
Jan 13 Javascript
实例讲解DataTables固定表格宽度(设置横向滚动条)
Jul 11 Javascript
说说Vue.js中的functional函数化组件的使用
Feb 12 Javascript
node.js实现微信开发之获取用户授权
Mar 18 Javascript
VUE 解决mode为history页面为空白的问题
Nov 01 Javascript
Element的el-tree控件后台数据结构的生成以及方法的抽取
Mar 05 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
关于file_get_contents返回为空或函数不可用的解决方案
2013/06/24 PHP
PHP set_error_handler()函数使用详解(示例)
2013/11/12 PHP
PHP的引用详解
2015/02/22 PHP
php  单例模式详细介绍及实现源码
2016/11/05 PHP
jquery选择器(常用选择器说明)
2010/09/28 Javascript
jWiard 基于JQuery的强大的向导控件介绍
2011/10/28 Javascript
模拟一个类似百度google的模糊搜索下拉列表
2014/04/15 Javascript
js获取IP地址的方法小结
2014/07/01 Javascript
js实现屏幕自适应局部代码分享
2015/01/30 Javascript
如何编写jquery插件
2017/03/29 jQuery
利用 spin.js 生成等待效果(js 等待效果)
2017/06/25 Javascript
nodejs async异步常用函数总结(推荐)
2017/11/17 NodeJs
原生JS实现的放大镜特效示例【测试可用】
2018/12/08 Javascript
p5.js绘制创意自画像
2019/11/04 Javascript
Vue 使用Props属性实现父子组件的动态传值详解
2019/11/13 Javascript
JS实现滑动导航效果
2020/01/14 Javascript
[43:24]完美世界DOTA2联赛PWL S3 INK ICE vs DLG 第二场 12.12
2020/12/17 DOTA
Python天气预报采集器实现代码(网页爬虫)
2012/10/07 Python
Python中的exec、eval使用实例
2014/09/23 Python
python reduce 函数使用详解
2017/12/05 Python
python+matplotlib实现礼盒柱状图实例代码
2018/01/16 Python
关于python下cv.waitKey无响应的原因及解决方法
2019/01/10 Python
Python流行ORM框架sqlalchemy安装与使用教程
2019/06/04 Python
python的re模块使用方法详解
2019/07/26 Python
Python可变对象与不可变对象原理解析
2020/02/25 Python
css3 position fixed固定居中问题解决方案
2014/08/19 HTML / CSS
CSS3 实现弹幕的示例代码
2017/08/07 HTML / CSS
12个不为大家熟知的HTML5设计小技巧
2016/06/02 HTML / CSS
如何利用cmp命令比较文件
2016/04/11 面试题
消防先进事迹材料
2014/02/10 职场文书
计算机专业毕业生求职信
2014/04/30 职场文书
国家税务局干部作风整顿整改措施
2014/09/18 职场文书
四查四看整改措施
2014/09/19 职场文书
小学二年级班主任工作经验交流材料
2015/11/02 职场文书
Python中Permission denied的解决方案
2021/04/02 Python
Oracle以逗号分隔的字符串拆分为多行数据实例详解
2021/07/16 Oracle