一个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动态的改变IFrame的高度实现自动伸展
Oct 12 Javascript
checkbox批量选中,获取选中项的值的简单实例
Jun 28 Javascript
jQuery插件ajaxFileUpload异步上传文件
Oct 19 Javascript
Layui组件Table绑定行点击事件和获取行数据的方法
Aug 19 Javascript
JointJS流程图的绘制方法
Dec 03 Javascript
VUE解决微信签名及SPA微信invalid signature问题(完美处理)
Mar 29 Javascript
微信小程序开发之map地图组件定位并手动修改位置偏差
Aug 17 Javascript
layui 弹出层回调获取弹出层数据的例子
Sep 02 Javascript
layui将table转化表单显示的方法(即table.render转为表单展示)
Sep 24 Javascript
微信小程序停止其他视频播放当前视频的实例代码
Dec 25 Javascript
浅谈VUE中演示v-for为什么要加key
Jan 16 Javascript
Javascript实现贪吃蛇小游戏(含详细注释)
Oct 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静态调用非静态方法的应用分析
2013/05/02 PHP
如何使用PHP Embed SAPI实现Opcodes查看器
2015/11/10 PHP
php将html转为图片的实现方法
2017/05/19 PHP
使用PHP连接数据库_实现用户数据的增删改查的整体操作示例
2017/09/01 PHP
PHP 实现 WebSocket 协议原理与应用详解
2020/04/22 PHP
脚本吧 - 幻宇工作室用到js,超强推荐share.js
2006/12/23 Javascript
jQuery 使用手册(四)
2009/09/23 Javascript
javascript 跳转代码集合
2009/12/03 Javascript
js中cookie的添加、取值、删除示例代码
2013/10/21 Javascript
在jquery boxy中添加百度地图坐标拾取注意流程
2014/04/03 Javascript
基于JavaScript实现前端文件的断点续传
2016/10/17 Javascript
jQuery实现手机上输入后隐藏键盘功能
2017/01/04 Javascript
jQuery EasyUI 页面加载等待及页面等待层
2017/02/06 Javascript
基于canvas的二维码邀请函生成插件
2017/02/14 Javascript
浅谈关于.vue文件中style的scoped属性
2017/08/19 Javascript
jQuery滚动条美化插件nicescroll简单用法示例
2018/04/18 jQuery
JS实现自定义弹窗功能
2018/08/08 Javascript
Javascript中绑定click事件的四种方式介绍
2018/10/26 Javascript
VUE 自定义组件模板的方法详解
2019/08/30 Javascript
layui导出所有数据的例子
2019/09/10 Javascript
详解vue beforeEach 死循环问题解决方法
2020/02/25 Javascript
vue webpack build资源相对路径的问题及解决方法
2020/06/04 Javascript
Python多线程同步Lock、RLock、Semaphore、Event实例
2014/11/21 Python
Python3.x爬虫下载网页图片的实例讲解
2018/05/22 Python
python爬虫刷访问量 2019 7月
2019/08/01 Python
Python基于os.environ从windows获取环境变量
2020/06/09 Python
Pytho爬虫中Requests设置请求头Headers的方法
2020/09/22 Python
英国领先的野生鸟类食品供应商:GardenBird
2018/08/09 全球购物
盛大二次面试题
2016/11/18 面试题
什么是规则表达式
2012/05/03 面试题
自荐信格式的六要素
2013/09/21 职场文书
见习期自我鉴定
2014/01/31 职场文书
作文批改评语
2014/12/25 职场文书
周一问候语大全
2015/11/10 职场文书
Java Socket实现Redis客户端的详细说明
2021/05/26 Redis
使用python+pygame开发消消乐游戏附完整源码
2021/06/10 Python