近日項目有個新需求,須要對視頻或音頻進行多段裁剪而後拼接。例如,一段視頻長30分鐘,我須要將5-10分鐘、17-22分鐘、24-29分鐘這三段拼接到一塊兒成一整段視頻。裁剪在前端,拼接在後端。javascript
網上簡單找了找,基本都是客戶端內的工具,沒有純網頁的裁剪。既然沒有,那就動手寫一個。前端
歡迎Star github.com/fengma1992/…vue
廢話很少,下面就來看看怎麼設計的。java
圖中底部的功能塊爲裁剪工具組件,上方的視頻爲演示用,固然也能是音頻。git
*注:項目中的圖標都替換成了文字github
總體來看,經過一個數據數組cropItemList
來保存用戶輸入數據,不論是鼠標拖拽仍是鍵盤輸入,都來操做cropItemList
實現兩側數據聯動。最後經過處理cropItemList
來輸出用戶想要的裁剪。後端
cropItemList
結構以下:數組
cropItemList: [
{
startTime: 0, // 開始時間
endTime: 100, // 結束時間
startTimeArr: [hoursStr, minutesStr, secondsStr], // 時分秒字符串
endTimeArr: [hoursStr, minutesStr, secondsStr], // 時分秒字符串
startTimeIndicatorOffsetX: 0, // 開始時間在左側拖動區X偏移量
endTimeIndicatorOffsetX: 100, // 結束時間在左側拖動區X偏移量
}
]
複製代碼
既然是多段裁剪,那麼用戶得知道裁剪了哪些時間段,這經過右側的裁剪列表來呈現。bash
列表存在三個狀態:app
v-for
去循環
cropItemList
,那麼就會出現下圖的情況:
並且,第1條最右側是添加按鈕,而剩下的最右側都是刪除按鈕。因此,咱們
將第1條單獨提出來寫,而後將cropItemList
逆序生成一個renderList
並循環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>
複製代碼
下圖爲最終效果:
這個其實就是寫三個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
事件並帶上播放起始時間。一樣還有pause
和stop
事件,來控制媒體暫停與中止。
<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
事件並實時對比媒體的currentTime
與playingItem
的endTime
,達到的時候就發出pause
事件通知媒體暫停。
if (currentTime >= playingItem.endTime) {
this.pause()
}
複製代碼
至此,鍵盤輸入的裁剪列表基本完成,下面介紹鼠標拖拽輸入。
下面介紹如何經過鼠標點擊與拖拽輸入。
新增裁剪
鼠標在拖拽區點擊後,新增一條裁剪數據,開始時間與結束時間均爲mouseup
時進度條的時間,並讓結束時間戳跟隨鼠標移動,進入編輯狀態。
確認時間戳
編輯狀態,鼠標移動時,時間戳根據鼠標在進度條的當前位置來隨動,鼠標再次點擊後確認當前時間,並終止時間戳跟隨鼠標移動。
更改時間
非編輯狀態,鼠標在進度條上移動時,監聽mousemove
事件,在接近任意一條裁剪數據的開始或結束時間戳時高亮當前數據並顯示時間戳。鼠標mousedown
後選中時間戳並開始拖拽更改時間數據。mouseup
後結束更改。
鼠標在進度條區域須要監聽三個事件:mousedown
、mousemove
、mouseup
。 在進度條區存在多種元素,簡單可分紅三類:
首先mousedown
和mouseup
的監聽固然是綁定在進度條自己。
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)()
})
複製代碼
首先是時間戳捕獲,當mousemove
時,將全部裁剪片斷遍歷,檢測鼠標當前位置是否靠近裁剪片斷的時間戳,當鼠標位置和時間戳位置相差小於2則認爲是靠近(2個像素的範圍)。
/** * 檢測鼠標是否接近 * @param x1 * @param x2 */
const isCursorClose = function (x1, x2) {
return Math.abs(x1 - x2) < 2
}
複製代碼
檢測爲true
則高亮時間戳及時間戳對應的片斷,經過cropItemHoverIndex
變量來記錄當前鼠標hover
的時間戳,
同時,鼠標mousedown
可選中hover
的時間戳並進行拖動。
下面是時間戳檢測和時間戳拖動檢測代碼
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)
}
}
複製代碼
裁剪完成後下一步固然是把數據丟給後端啦。
用戶使用的時候小手一抖,多點了一下添加
按鈕,或者有帕金森,怎麼都拖不許,就可能會有數據同樣或存在重合部分的裁剪片斷。那麼咱們就得過濾掉重複並將存在重合部分的裁剪合成一段。
仍是直接看代碼方便
/**
* 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>
複製代碼
寫博客比寫代碼難多了,感受很混亂的寫完了這個博客。
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
四個key
的Object
,每一個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')
};
}
複製代碼
GitHub:github.com/fengma1992/…