一個Vue媒體多段裁剪組件

前言

近日項目有個新需求,須要對視頻或音頻進行多段裁剪而後拼接。例如,一段視頻長30分鐘,我須要將5-10分鐘、17-22分鐘、24-29分鐘這三段拼接到一塊兒成一整段視頻。裁剪在前端,拼接在後端。javascript

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

代碼已上傳到GitHub

歡迎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

  • 無數據狀態

無數據的時候顯示內容爲空,當用戶點擊輸入框時主動爲他生成一條數據,默認爲視頻長度的1/4到3/4處。

  • 有一條數據

此時界面顯示很簡單,將惟一一條數據呈現。

  • 有多條數據

有多條數據時就得有額外處理了,由於第1條數據在最下方,而若是用 v-for去循環 cropItemList,那麼就會出現下圖的情況:
並且,第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>
複製代碼

下圖爲最終效果:

時分秒輸入

這個其實就是寫三個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()
}
複製代碼

至此,鍵盤輸入的裁剪列表基本完成,下面介紹鼠標拖拽輸入。

第二步

下面介紹如何經過鼠標點擊與拖拽輸入。

一、肯定鼠標交互邏輯

  • 新增裁剪

    鼠標在拖拽區點擊後,新增一條裁剪數據,開始時間與結束時間均爲mouseup時進度條的時間,並讓結束時間戳跟隨鼠標移動,進入編輯狀態。

  • 確認時間戳

    編輯狀態,鼠標移動時,時間戳根據鼠標在進度條的當前位置來隨動,鼠標再次點擊後確認當前時間,並終止時間戳跟隨鼠標移動。

  • 更改時間

    非編輯狀態,鼠標在進度條上移動時,監聽mousemove事件,在接近任意一條裁剪數據的開始或結束時間戳時高亮當前數據並顯示時間戳。鼠標mousedown後選中時間戳並開始拖拽更改時間數據。mouseup後結束更改。

二、肯定須要監聽的鼠標事件

鼠標在進度條區域須要監聽三個事件: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)()
})
複製代碼

三、實現拖拽與時間戳隨動

首先是時間戳捕獲,當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四個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/…

相關文章
相關標籤/搜索