一個(gè)Vue視頻媒體多段裁剪組件的實(shí)現(xiàn)示例
近日項(xiàng)目有個(gè)新需求,需要對(duì)視頻或音頻進(jìn)行多段裁剪然后拼接。例如,一段視頻長(zhǎng)30分鐘,我需要將5-10分鐘、17-22分鐘、24-29分鐘這三段拼接到一起成一整段視頻。裁剪在前端,拼接在后端。
網(wǎng)上簡(jiǎn)單找了找,基本都是客戶端內(nèi)的工具,沒(méi)有純網(wǎng)頁(yè)的裁剪。既然沒(méi)有,那就動(dòng)手寫(xiě)一個(gè)。
代碼已上傳到GitHub: https://github.com/fengma1992/media-cut-tool
廢話不多,下面就來(lái)看看怎么設(shè)計(jì)的。
效果圖

圖中底部的功能塊為裁剪工具組件,上方的視頻為演示用,當(dāng)然也能是音頻。
功能特點(diǎn):
- 支持鼠標(biāo)拖拽輸入與鍵盤(pán)數(shù)字輸入兩種模式;
- 支持預(yù)覽播放指定裁剪片段;
- 左側(cè)鼠標(biāo)輸入與右側(cè)鍵盤(pán)輸入聯(lián)動(dòng);
- 鼠標(biāo)移動(dòng)時(shí)自動(dòng)捕捉高亮拖拽條;
- 確認(rèn)裁剪時(shí)自動(dòng)去重;
*注:項(xiàng)目中的圖標(biāo)都替換成了文字
思路
整體來(lái)看,通過(guò)一個(gè)數(shù)據(jù)數(shù)組 cropItemList 來(lái)保存用戶輸入數(shù)據(jù),不管是鼠標(biāo)拖拽還是鍵盤(pán)輸入,都來(lái)操作 cropItemList 實(shí)現(xiàn)兩側(cè)數(shù)據(jù)聯(lián)動(dòng)。最后通過(guò)處理 cropItemList 來(lái)輸出用戶想要的裁剪。
cropItemList 結(jié)構(gòu)如下:
cropItemList: [
{
startTime: 0, // 開(kāi)始時(shí)間
endTime: 100, // 結(jié)束時(shí)間
startTimeArr: [hoursStr, minutesStr, secondsStr], // 時(shí)分秒字符串
endTimeArr: [hoursStr, minutesStr, secondsStr], // 時(shí)分秒字符串
startTimeIndicatorOffsetX: 0, // 開(kāi)始時(shí)間在左側(cè)拖動(dòng)區(qū)X偏移量
endTimeIndicatorOffsetX: 100, // 結(jié)束時(shí)間在左側(cè)拖動(dòng)區(qū)X偏移量
}
]
第一步
既然是多段裁剪,那么用戶得知道裁剪了哪些時(shí)間段,這通過(guò)右側(cè)的裁剪列表來(lái)呈現(xiàn)。
列表
列表存在三個(gè)狀態(tài):
無(wú)數(shù)據(jù)狀態(tài)

無(wú)數(shù)據(jù)的時(shí)候顯示內(nèi)容為空,當(dāng)用戶點(diǎn)擊輸入框時(shí)主動(dòng)為他生成一條數(shù)據(jù),默認(rèn)為視頻長(zhǎng)度的1/4到3/4處。
有一條數(shù)據(jù)

此時(shí)界面顯示很簡(jiǎn)單,將唯一一條數(shù)據(jù)呈現(xiàn)。
有多條數(shù)據(jù)

有多條數(shù)據(jù)時(shí)就得有額外處理了,因?yàn)榈?條數(shù)據(jù)在最下方,而如果用 v-for 去循環(huán) cropItemList,那么就會(huì)出現(xiàn)下圖的狀況:

而且,第1條最右側(cè)是添加按鈕,而剩下的最右側(cè)都是刪除按鈕。所以,我們 將第1條單獨(dú)提出來(lái)寫(xiě),然后將 cropItemList 逆序生成一個(gè) renderList 并循環(huán) renderList 的 0 -> listLength - 2 條
即可。
<template v-for="(item, index) in renderList"> <div v-if="index < listLength -1" :key="index" class="crop-time-item"> ... ... </div> </template>
下圖為最終效果:

時(shí)分秒輸入
這個(gè)其實(shí)就是寫(xiě)三個(gè) input 框,設(shè) type="text" (設(shè)成 type=number 輸入框右側(cè)會(huì)有上下箭頭),然后通過(guò)監(jiān)聽(tīng)input事件來(lái)保證輸入的正確性并更新數(shù)據(jù)。監(jiān)聽(tīng)focus事件來(lái)確定是否需要在 cropItemList 為空時(shí)主動(dòng)添加一條數(shù)據(jù)。
<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>
播放片段
點(diǎn)擊播放按鈕時(shí)會(huì)通過(guò) playingItem 記錄當(dāng)前播放的片段,然后向上層發(fā)出 play 事件并帶上播放起始時(shí)間。同樣還有 pause 和 stop 事件,來(lái)控制媒體暫停與停止。
<CropTool :duration="duration" :playing="playing" :currentPlayingTime="currentTime" @play="playVideo" @pause="pauseVideo" @stop="stopVideo"/>
/**
* 播放選中片段
* @param index
*/
playSelectedClip: function (index) {
if (!this.listLength) {
console.log('無(wú)裁剪片段')
return
}
this.playingItem = this.cropItemList[index]
this.playingIndex = index
this.isCropping = false
this.$emit('play', this.playingItem.startTime || 0)
}
這里控制了開(kāi)始播放,那么如何讓媒體播到裁剪結(jié)束時(shí)間的時(shí)候自動(dòng)停止呢?
監(jiān)聽(tīng)媒體的 timeupdate 事件并實(shí)時(shí)對(duì)比媒體的 currentTime 與 playingItem 的 endTime ,達(dá)到的時(shí)候就發(fā)出 pause 事件通知媒體暫停。
if (currentTime >= playingItem.endTime) {
this.pause()
}
至此,鍵盤(pán)輸入的裁剪列表基本完成,下面介紹鼠標(biāo)拖拽輸入。
第二步
下面介紹如何通過(guò)鼠標(biāo)點(diǎn)擊與拖拽輸入。
1、確定鼠標(biāo)交互邏輯
新增裁剪
鼠標(biāo)在拖拽區(qū)點(diǎn)擊后,新增一條裁剪數(shù)據(jù),開(kāi)始時(shí)間與結(jié)束時(shí)間均為 mouseup 時(shí)進(jìn)度條的時(shí)間,并讓結(jié)束時(shí)間戳跟隨鼠標(biāo)移動(dòng),進(jìn)入編輯狀態(tài)。
確認(rèn)時(shí)間戳
編輯狀態(tài),鼠標(biāo)移動(dòng)時(shí),時(shí)間戳根據(jù)鼠標(biāo)在進(jìn)度條的當(dāng)前位置來(lái)隨動(dòng),鼠標(biāo)再次點(diǎn)擊后確認(rèn)當(dāng)前時(shí)間,并終止時(shí)間戳跟隨鼠標(biāo)移動(dòng)。
更改時(shí)間
非編輯狀態(tài),鼠標(biāo)在進(jìn)度條上移動(dòng)時(shí),監(jiān)聽(tīng) mousemove 事件,在接近任意一條裁剪數(shù)據(jù)的開(kāi)始或結(jié)束時(shí)間戳?xí)r高亮當(dāng)前數(shù)據(jù)并顯示時(shí)間戳。鼠標(biāo) mousedown 后選中時(shí)間戳并開(kāi)始拖拽更改時(shí)間數(shù)據(jù)。 mouseup 后結(jié)束更改。
2、確定需要監(jiān)聽(tīng)的鼠標(biāo)事件
鼠標(biāo)在進(jìn)度條區(qū)域需要監(jiān)聽(tīng)三個(gè)事件: mousedown 、 mousemove 、 mouseup 。 在進(jìn)度條區(qū)存在多種元素,簡(jiǎn)單可分成三類:
- 鼠標(biāo)移動(dòng)時(shí)隨動(dòng)的時(shí)間戳
- 存在裁剪片段時(shí)的開(kāi)始時(shí)間戳、結(jié)束時(shí)間戳、淺藍(lán)色的時(shí)間遮罩
- 進(jìn)度條本身
首先 mousedown 和 mouseup 的監(jiān)聽(tīng)當(dāng)然是綁定在進(jìn)度條本身。
this.timeLineContainer.addEventListener('mousedown', e => {
const currentCursorOffsetX = e.clientX - containerLeft
lastMouseDownOffsetX = currentCursorOffsetX
// 檢測(cè)是否點(diǎn)到了時(shí)間戳
this.timeIndicatorCheck(currentCursorOffsetX, 'mousedown')
})
this.timeLineContainer.addEventListener('mouseup', e => {
// 已經(jīng)處于裁剪狀態(tài)時(shí),鼠標(biāo)抬起,則裁剪狀態(tài)取消
if (this.isCropping) {
this.stopCropping()
return
}
const currentCursorOffsetX = this.getFormattedOffsetX(e.clientX - containerLeft)
// mousedown與mouseup位置不一致,則不認(rèn)為是點(diǎn)擊,直接返回
if (Math.abs(currentCursorOffsetX - lastMouseDownOffsetX) > 3) {
return
}
// 更新當(dāng)前鼠標(biāo)指向的時(shí)間
this.currentCursorTime = currentCursorOffsetX * this.timeToPixelRatio
// 鼠標(biāo)點(diǎn)擊新增裁剪片段
if (!this.isCropping) {
this.addNewCropItemInSlider()
// 新操作位置為數(shù)組最后一位
this.startCropping(this.cropItemList.length - 1)
}
})
mousemove 這個(gè),當(dāng)非編輯狀態(tài)時(shí),當(dāng)然是監(jiān)聽(tīng)進(jìn)度條來(lái)實(shí)現(xiàn)時(shí)間戳隨動(dòng)鼠標(biāo)。而當(dāng)需要選中開(kāi)始或結(jié)束時(shí)間戳來(lái)進(jìn)入編輯狀態(tài)時(shí),我最初設(shè)想的是監(jiān)聽(tīng)時(shí)間戳本身,來(lái)達(dá)到選中時(shí)間戳的目的。而實(shí)際情況是:當(dāng)鼠標(biāo)接近開(kāi)始或結(jié)束時(shí)間戳?xí)r,一直有一個(gè)鼠標(biāo)隨動(dòng)的時(shí)間戳擋在前面,而且因?yàn)椴眉羝卫碚撋峡梢詿o(wú)限增加,那我得監(jiān)聽(tīng)2*裁剪片段個(gè) mousemove 。
基于此,只在進(jìn)度條本身監(jiān)聽(tīng) mousemove ,通過(guò)實(shí)時(shí)比對(duì)鼠標(biāo)位置和時(shí)間戳位置來(lái)確定是否到了相應(yīng)位置, 當(dāng)然得加一個(gè) throttle 節(jié)流。
this.timeLineContainer.addEventListener('mousemove', e => {
throttle(() => {
const currentCursorOffsetX = e.clientX - containerLeft
// mousemove范圍檢測(cè)
if (currentCursorOffsetX < 0 || currentCursorOffsetX > containerWidth) {
this.isCursorIn = false
// 鼠標(biāo)拖拽狀態(tài)到達(dá)邊界直接觸發(fā)mouseup狀態(tài)
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
// 時(shí)間戳檢測(cè)
this.timeIndicatorCheck(currentCursorOffsetX, 'mousemove')
// 時(shí)間戳移動(dòng)檢測(cè)
this.timeIndicatorMove(currentCursorOffsetX)
}, 10, true)()
})
3、實(shí)現(xiàn)拖拽與時(shí)間戳隨動(dòng)
下面是時(shí)間戳檢測(cè)和時(shí)間戳移動(dòng)檢測(cè)代碼
timeIndicatorCheck (currentCursorOffsetX, mouseEvent) {
// 在裁剪狀態(tài),直接返回
if (this.isCropping) {
return
}
// 鼠標(biāo)移動(dòng),重設(shè)hover狀態(tài)
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
}
// 默認(rèn)始末時(shí)間戳在一起時(shí)優(yōu)先選中截止時(shí)間戳
if (isCursorClose(item.endTimeIndicatorOffsetX, currentCursorOffsetX)) {
this.endTimeIndicatorHoverIndex = index
// 鼠標(biāo)放下,開(kāi)始裁剪
if (mouseEvent === 'mousedown') {
this.endTimeIndicatorDraggingIndex = index
this.currentEditingIndex = index
this.isCropping = true
}
}
else if (isCursorClose(item.startTimeIndicatorOffsetX, currentCursorOffsetX)) {
this.startTimeIndicatorHoverIndex = index
// 鼠標(biāo)放下,開(kāi)始裁剪
if (mouseEvent === 'mousedown') {
this.startTimeIndicatorDraggingIndex = index
this.currentEditingIndex = index
this.isCropping = true
}
}
})
},
timeIndicatorMove (currentCursorOffsetX) {
// 裁剪狀態(tài),隨動(dòng)時(shí)間戳
if (this.isCropping) {
const currentEditingIndex = this.currentEditingIndex
const startTimeIndicatorDraggingIndex = this.startTimeIndicatorDraggingIndex
const endTimeIndicatorDraggingIndex = this.endTimeIndicatorDraggingIndex
const currentCursorTime = this.currentCursorTime
let currentItem = this.cropItemList[currentEditingIndex]
// 操作起始位時(shí)間戳
if (startTimeIndicatorDraggingIndex > -1 && currentItem) {
// 已到截止位時(shí)間戳則直接返回
if (currentCursorOffsetX > currentItem.endTimeIndicatorOffsetX) {
return
}
currentItem.startTimeIndicatorOffsetX = currentCursorOffsetX
currentItem.startTime = currentCursorTime
}
// 操作截止位時(shí)間戳
if (endTimeIndicatorDraggingIndex > -1 && currentItem) {
// 已到起始位時(shí)間戳則直接返回
if (currentCursorOffsetX < currentItem.startTimeIndicatorOffsetX) {
return
}
currentItem.endTimeIndicatorOffsetX = currentCursorOffsetX
currentItem.endTime = currentCursorTime
}
this.updateCropItem(currentItem, currentEditingIndex)
}
}
第三步
裁剪完成后下一步當(dāng)然是把數(shù)據(jù)丟給后端啦。
把用戶當(dāng):sweet_potato:(#紅薯#)
用戶使用的時(shí)候小手一抖,多點(diǎn)了一下 添加 按鈕,或者有帕金森,怎么都拖不準(zhǔn),就可能會(huì)有數(shù)據(jù)一樣或存在重合部分的裁剪片段。那么我們就得過(guò)濾掉重復(fù)和存在重合部分的裁剪。
還是直接看代碼方便
/**
* cropItemList排序并去重
*/
cleanCropItemList () {
let cropItemList = this.cropItemList
// 1. 依據(jù)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
// 遍歷,刪除重復(fù)片段
cropItemList.forEach((item, index) => {
// 遍歷到最后一項(xiàng),直接寫(xiě)入
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無(wú)重疊,向列表添加一項(xiàng),更新記錄參數(shù)
if (item.startTime > endTime) {
tempCropItemList.push({
startTime: startTime,
endTime: endTime,
startTimeArr: formatTime.getFormatTimeArr(startTime),
endTimeArr: formatTime.getFormatTimeArr(endTime),
})
// 標(biāo)志量移到當(dāng)前item
startTime = item.startTime
endTime = item.endTime
}
})
return tempCropItemList
}
第四步
使用裁剪工具: 通過(guò)props及emit事件實(shí)現(xiàn)媒體與裁剪工具之間的通信。
<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>
總結(jié)
寫(xiě)博客比寫(xiě)代碼難多了,感覺(jué)很混亂的寫(xiě)完了這個(gè)博客。
幾個(gè)小細(xì)節(jié) 列表增刪時(shí)的高度動(dòng)畫(huà)

UI提了個(gè)需求,最多展示10條裁剪片段,超過(guò)了之后就滾動(dòng),還得有增刪動(dòng)畫(huà)。本來(lái)以為直接設(shè)個(gè) max-height 完事,結(jié)果發(fā)現(xiàn)
CSS的 transition 動(dòng)畫(huà)只有針對(duì)絕對(duì)值的height有效 ,這就有點(diǎn)小麻煩,因?yàn)椴眉魲l數(shù)是變化的,那么高度也是在變化的。設(shè)絕對(duì)值該怎么辦呢。。。
這里通過(guò)HTML中tag的 attribute 屬性 data-count 來(lái)告訴CSS我現(xiàn)在有幾條裁剪,然后讓CSS根據(jù) data-count 來(lái)設(shè)置列表高度。
<!--超過(guò)10條數(shù)據(jù)也只傳10,讓列表滾動(dòng)--> <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 時(shí)事件的 currentTarget 問(wèn)題
因?yàn)榇嬖贒OM事件的捕獲與冒泡,而進(jìn)度條上面可能有別的如時(shí)間戳、裁剪片段等元素, mousemove 事件的 currentTarget 可能會(huì)變,導(dǎo)致取鼠標(biāo)距離進(jìn)度條最左側(cè)的 offsetX 可能有問(wèn)題;而如果通過(guò)檢測(cè) currentTarget 是否為進(jìn)度條也存在問(wèn)題,因?yàn)槭髽?biāo)移動(dòng)的時(shí)候一直有個(gè)時(shí)間戳在隨動(dòng),導(dǎo)致偶爾一段時(shí)間都觸發(fā)不了進(jìn)度條對(duì)應(yīng)的 mousemove 事件。
解決辦法就是,頁(yè)面加載完成后取得進(jìn)度條最左側(cè)距頁(yè)面最左側(cè)的距離, mousemove 事件不取 offsetX ,轉(zhuǎn)而取基于頁(yè)面最左側(cè)的 clientX ,然后兩者相減就得到了鼠標(biāo)距離進(jìn)度條最左側(cè)的像素值。代碼在上文中的添加 mousemove 監(jiān)聽(tīng)里已寫(xiě)。
時(shí)間格式化
因?yàn)椴眉艄ぞ吆芏嗟胤叫枰獙⒚朕D(zhuǎn)換為 00:00:00 格式的字符串,因此寫(xiě)了一個(gè)工具函數(shù):輸入秒,輸出一個(gè)包含 dd,HH,mm,ss 四個(gè) key 的 Object ,每個(gè) key 為長(zhǎng)度為2的字符串。用ES8的 String.prototype.padStart() 方法實(shí)現(xiàn)。
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')
};
}
以上就是本文的全部?jī)?nèi)容,希望對(duì)大家的學(xué)習(xí)有所幫助,也希望大家多多支持腳本之家。
- Vue圖片裁剪組件實(shí)例代碼
- vue-image-crop基于Vue的移動(dòng)端圖片裁剪組件示例
- 基于cropper.js封裝vue實(shí)現(xiàn)在線圖片裁剪組件功能
- vue-cli結(jié)合Element-ui基于cropper.js封裝vue實(shí)現(xiàn)圖片裁剪組件功能
- 基于Vue的移動(dòng)端圖片裁剪組件功能
- vue+element實(shí)現(xiàn)圖片上傳及裁剪功能
- vue實(shí)現(xiàn)移動(dòng)端圖片裁剪上傳功能
- Vue-cropper 圖片裁剪的基本原理及思路講解
- vue項(xiàng)目實(shí)現(xiàn)添加圖片裁剪組件
相關(guān)文章
vue中使用echarts制作圓環(huán)圖的實(shí)例代碼
這篇文章主要介紹了vue中使用echarts制作圓環(huán)圖的實(shí)例代碼,代碼簡(jiǎn)單易懂,非常不錯(cuò),具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2018-07-07
Vue2.0使用過(guò)程常見(jiàn)的一些問(wèn)題總結(jié)學(xué)習(xí)
本篇文章主要介紹了Vue2.0使用過(guò)程常見(jiàn)的一些問(wèn)題總結(jié)學(xué)習(xí),詳細(xì)的介紹了使用中會(huì)遇到的各種錯(cuò)誤,有興趣的可以了解一下。2017-04-04
vue3實(shí)現(xiàn)局部頁(yè)面刷新效果的示例詳解
這篇文章主要為大家詳細(xì)介紹了vue3如何采用 App.vue定義全局變量與方法并實(shí)現(xiàn)局部頁(yè)面刷新效果,文中的示例代碼講解詳細(xì),需要的可以參考一下2024-01-01

