Компонент многосегментной обрезки Vue media

внешний интерфейс GitHub Vue.js CSS
Компонент многосегментной обрезки Vue media

changelog

2020.04.20 Исправлен ряд ошибок, оптимизированы некоторые анимации, оптимизирован режим взаимодействия.

предисловие

В последнее время в проекте появилось новое требование, требующее многократной обрезки и склейки видео или аудио. Например, видео длится 30 минут, и мне нужно соединить три сегмента 5-10 минут, 17-22 минуты и 24-29 минут вместе в единое видео. Укороченный спереди, простроченный сзади.

Я просто искал в Интернете, и это в основном инструменты в клиенте, без вырезания чистых веб-страниц. Если у вас его нет, напишите его сами.

Код загружен на GitHub

Добро пожаловать в ЗвездуGitHub.com/Сумасшедшая лошадь1992/…

Не много ерунды, давайте посмотрим, как она устроена.

визуализация

Функциональный блок в нижней части рисунка — компонент инструмента обрезки, а видео выше — для демонстрации, и, конечно, это может быть и звук.

Функции:

  • Поддерживает два режима ввода с помощью перетаскивания мышью и ввода с клавиатуры;
  • Поддержка предварительного просмотра указанных клипов;
  • Ввод левой кнопкой мыши связан с вводом правой клавиатуры;
  • Автоматически захватывать выделенную полосу перетаскивания при перемещении мыши;
  • Автоматическая дедупликация при подтверждении кадрирования;

*Примечание: значки в проекте заменены текстом

идеи

Всего через массив данныхcropItemListЧтобы сохранить данные пользователя ввода, будь то перетаскивание мыши или ввод клавиатуры, работаютcropItemListРеализуйте связь данных с обеих сторон. Наконец, обработавcropItemListдля вывода урожая, который хочет пользователь.

cropItemListСтруктура выглядит следующим образом:

cropItemList: [
    {
        startTime: 0, // 开始时间
        endTime: 100, // 结束时间
        startTimeArr: [hoursStr, minutesStr, secondsStr], // 时分秒字符串
        endTimeArr: [hoursStr, minutesStr, secondsStr], // 时分秒字符串
        startTimeIndicatorOffsetX: 0, // 开始时间在左侧拖动区X偏移量
        endTimeIndicatorOffsetX: 100, // 结束时间在左侧拖动区X偏移量
    }
]

первый шаг

Поскольку это многосегментная культура, пользователь должен знать, какие периоды времени обрезаются, что представлено в списке культур справа.

список

Списки существуют в трех состояниях:

  • нет статуса данных

Когда нет данных, отображаемое содержимое пусто.Когда пользователь щелкает поле ввода, для него автоматически генерируется часть данных, и по умолчанию это от 1/4 до 3/4 длины видео.

  • есть данные

В настоящее время отображение интерфейса очень простое, и представлена ​​только одна часть данных.

  • несколько данных

При наличии нескольких фрагментов данных требуется дополнительная обработка, так как первый фрагмент данных находится внизу, и если вы используетеv-forзацикливатьсяcropItemList, то получится следующая ситуация:
Кроме того, самая правая часть первой панели — это кнопка добавления, а остальные крайние правые — кнопки удаления. поэтому мыНапишите пункт 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В правой части поля ввода будут стрелки вверх и вниз), а затем прослушайте событие ввода, чтобы убедиться в правильности ввода и обновить данные. Прослушайте событие фокуса, чтобы определить, нужно ли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()
}

На этом список обрезки ввода с клавиатуры в основном завершен, и ниже описывается ввод с помощью перетаскивания мышью.

второй шаг

Ниже описано, как вводить с помощью щелчка мыши и перетаскивания.

1. Определить логику взаимодействия с мышью

  • Добавить обрезку

    После щелчка мышью в области перетаскивания добавляются новые данные отсечения, а также время начала и время окончания.mouseupЗасеките время индикатора выполнения и позвольте отметке времени окончания переместиться с помощью мыши, чтобы войти в состояние редактирования.

  • временная метка подтверждения

    В состоянии редактирования, когда мышь перемещается, отметка времени следует за текущим положением мыши на индикаторе выполнения.После повторного щелчка мыши текущее время подтверждается, и отметка времени прерывается, чтобы следовать за движением мыши.

  • Время смены

    В нередактируемом состоянии, когда мышь перемещается по индикатору выполнения, отслеживайтеmousemoveСобытие, которое выделяет текущие данные и отображает временную метку, когда она приближается к начальной или конечной временной метке любого фрагмента обрезанных данных. мышьmousedownЗатем выберите метку времени и начните перетаскивать, чтобы изменить данные времени.mouseupчтобы закончить изменения.

2. Определите события мыши, которые необходимо отслеживать

Мышь должна отслеживать три события в области индикатора выполнения: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)()
})

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
}

четвертый шаг

Используйте инструмент обрезки: Связь между медиа и инструментами обрезки достигается с помощью реквизита и генерирования событий.

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

Суммировать

Писать блог намного сложнее, чем писать код, и мне кажется, что закончить писать этот блог — это беспорядок.

несколько мелких деталей

Анимация высоты при добавлении или удалении списка

Пользовательский интерфейс выдвигает требование отображать не более 10 вырезок, прокручивая более одного, а также добавляя и удаляя анимацию. Я думал, что прямойmax-heightГотово, оказывается

CSS.transitionАнимации действительны только для абсолютных высот., что немного хлопотно, потому что меняется количество полос обрезки, поэтому меняется и высота. Как установить абсолютное значение. . .

Здесь через тег в HTMLattributeАтрибутыdata-countчтобы сообщить CSS, что у меня есть несколько клипов, а затем позволить CSSdata-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. с ES8String.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.com/Сумасшедшая лошадь1992/…