一個Vue媒體多段裁剪元件

fengma1992發表於2019-02-25

前言

近日專案有個新需求,需要對視訊或音訊進行多段裁剪然後拼接。例如,一段視訊長30分鐘,我需要將5-10分鐘、17-22分鐘、24-29分鐘這三段拼接到一起成一整段視訊。裁剪在前端,拼接在後端。

網上簡單找了找,基本都是客戶端內的工具,沒有純網頁的裁剪。既然沒有,那就動手寫一個。

程式碼已上傳到GitHub

歡迎Star github.com/fengma1992/…

廢話不多,下面就來看看怎麼設計的。

效果圖

一個Vue媒體多段裁剪元件

圖中底部的功能塊為裁剪工具元件,上方的視訊為演示用,當然也能是音訊。

功能特點:

  • 支援滑鼠拖拽輸入與鍵盤數字輸入兩種模式;
  • 支援預覽播放指定裁剪片段;
  • 左側滑鼠輸入與右側鍵盤輸入聯動;
  • 滑鼠移動時自動捕捉高亮拖拽條;
  • 確認裁剪時自動去重;

*注:專案中的圖示都替換成了文字

思路

整體來看,通過一個資料陣列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。 在進度條區存在多種元素,簡單可分成三類:

  • 滑鼠移動時隨動的時間戳
  • 存在裁剪片段時的開始時間戳、結束時間戳、淺藍色的時間遮罩
  • 進度條本身

首先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、實現拖拽與時間戳隨動

首先是時間戳捕獲,當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>
複製程式碼

總結

寫部落格比寫程式碼難多了,感覺很混亂的寫完了這個部落格。

幾個小細節

列表增刪時的高度動畫

一個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')
    };

}
複製程式碼

歡迎斧正

GitHub:github.com/fengma1992/…

相關文章