vue3 + 独立的数据管理 Store实现视频剪辑功能

剪辑视频功能

需求

功能点:拖拽阶段时间点、分割、撤销、重做、删除、播放/暂停、前进/后退1秒、前进/后退15秒、多选、缩放时间轴等基础功能;这是一个功能完整的视频剪辑时间轴组件,核心围绕片段管理和播放联动两大主线,通过 Store 驱动数据、Canvas 绘制刻度、DOM 渲染片段,实现了流畅的剪辑体验。

主要时间轴整体框架

┌─────────────────────────────────────────────────────────────┐

│ 时间轴编辑器 │

├─────────────┬───────────────────────────────────────────────┤

│ 控制栏 │ 播放控制、分割、撤销/重做、倍速、分辨率 │

├─────────────┼───────────────────────────────────────────────┤

│ 时间轴 │ Canvas 绘制刻度 + 片段拖拽 + 指示器 │

├─────────────┼───────────────────────────────────────────────┤

│ 滚动条 │ 自定义滚动条(支持拖拽和触摸板) │

└─────────────┴───────────────────────────────────────────────┘

核心功能模块

  1. 数据管理(Store 驱动)

    editVideoStore:管理剪辑片段列表、当前时间、撤销/重做栈

    reviewVideoStore:管理播放状态、当前时间、视频分辨率

    数据流向:Store → computed → 组件渲染

  2. 时间轴渲染(Canvas)

    duration → 计算刻度数/每格时间/每格宽度 → 绘制刻度线 → 显示时间文本

    自适应缩放:支持从 50% 到 2000%+ 的缩放

    缩放策略:gridToTime 最小到 1秒/格

  3. 片段操作(核心交互)

    Mac: ⌘+B(分割) ⌘+Z(撤销) ⌘+⇧+Z(重做) ⌫(删除)

    Win: Ctrl+B Ctrl+Z Ctrl+Shift+Z Delete

  4. 播放联动

    原视频播放 → 监听 currentTime → 计算时间轴位置 → 更新指示器

    时间轴拖拽 → 计算对应视频时间 → emit('sendCurrentTime') → 跳转视频

  5. 缩放系统

    滚轮缩放:Ctrl+滚轮,逐级放大/缩小

    按钮缩放:固定比例(50%→60%→...→100%→200%→400%...)

    缩放影响:pxPerGrid、gridToTime、gridNum 联动变化

关键技术实现

  1. 时间转换

    // 视频时间 ↔ 时间轴时间 互转

    timelinetsToVideoTs(timelineTs, ranges) // 时间轴→视频

    videoTimeChangeTimeLine(time) // 视频→时间轴(播放时)

  2. 滚动控制

    自定义滚动条,与 scrollLeft 双向绑定

    触摸板双指滑动:监听 @scroll 事件同步

    拖拽指示器到边缘时自动滚动(setInterval 控制)

  3. 拖拽防抖

    限制最小更新间隔(16ms)

    限制最大单次拖动距离(防异常跳跃)

    拖拽结束后统一提交变更(addClip)

数据流向图

状态流转图

核心代码举例

typescript 复制代码
// 从 Store 获取片段列表,映射为时间轴数据
const timelineData = ref<Phase[]>([])

// 片段数据结构
interface Phase {
  start_ts: number          // 原视频起始时间
  end_ts: number            // 原视频结束时间
  timeLineStart_ts?: number // 时间轴起始位置
  timeLineEnd_ts?: number   // 时间轴结束位置
  from_ai?: boolean         // 是否来自AI识别
  color?: string            // 片段颜色
  name?: string             // 片段名称
}

Canvas 刻度绘制

typescript 复制代码
// 核心渲染参数
const gridNum = ref<number>(0)        // 绘制的格数
const pxPerGrid = ref<number>(0)      // 每格宽度(px)
const gridToTime = ref<number>(0)     // 每格代表时间(秒)
const zoomPX = ref<number>(0)         // 当前像素/每秒
const moveLeft = ref<number>(0)       // 左右滚动偏移

// 初始化刻度计算:找到最合适的一屏展示全部时间
const initCalculateGrid = (totalDuration: number, minScaleWidth: number) => {
  const totalWidth = canvasContentWidth.value
  const maxScales = Math.round(totalWidth / minScaleWidth)
  
  function recursiveCalculate(grid) {
    let gridTime = totalDuration / grid
    let gridWidth = totalWidth / grid
    
    if (gridTime >= 1 && gridWidth > gridMinWidth) {
      return { grid, gridTime, gridWidth }
    }
    return recursiveCalculate(grid - 1)
  }
  
  return recursiveCalculate(maxScales)
}

// 绘制刻度线和时间文本
const generatingScale = () => {
  for (let i = 0; i <= gridNum.value + 1; i++) {
    const timeText = graduationTimeText(i)
    const x = i * pxPerGrid.value - moveLeft.value
    
    ctx.fillText(timeText, x + 2, 10)
    drawLine(x, 1, 24, '#E9E9E9', 2)      // 主刻度
    drawLine(x + 0.5 * pxPerGrid.value, 1, 14, '#E9E9E9', 2) // 小刻度
  }
}

片段拖拽(核心交互)

typescript 复制代码
// 拖拽状态
const isPhaseDrag = ref<boolean>(false)
const currentDirection = ref<string>('') // 'start' | 'end' | 'content'
const dragX = ref<number>(0)

// 拖拽开始
const handleDragStart = (event, index, direction) => {
  timeLineIndex.value = index
  currentDirection.value = direction
  startX.value = event.clientX
  isPhaseDrag.value = true
  dragInProgress.value = true
  
  window.addEventListener('mousemove', handleDrag)
  window.addEventListener('mouseup', handleDragEnd)
}

// 拖拽过程中
const handleDrag = async (event) => {
  const delta = (event.clientX - startX.value) / zoomPX.value
  
  if (direction === 'start') {
    // 修改片段起始时间
    phases[index].start_ts += delta
    phases[index].timeLineStart_ts += delta
    // 边界限制:不能小于0,不能大于结束时间
    if (phases[index].start_ts >= endTime) {
      phases[index].start_ts = endTime
    }
  } else if (direction === 'end') {
    // 修改片段结束时间
    phases[index].end_ts += delta
    phases[index].timeLineEnd_ts += delta
    // 边界限制:不能小于起始时间,不能大于总时长
  }
  
  startX.value = event.clientX
  operationalData.value = phases
}

// 拖拽结束 - 提交变更
const handleDragEnd = async (event) => {
  if (whetherOrNotDrag.value && operationalData.value) {
    await editVideoStore().addClip(operationalData.value)
    await dragChangeRatio(lastDuration)  // 更新缩放比例
  }
}

多选与批量操作

typescript 复制代码
// Ctrl + 鼠标拖拽框选
const handleMousedown = async (event) => {
  if (isCtrlKeyDown.value) {
    isDragging.value = true
    startX.value = event.clientX
    startY.value = event.clientY - videoH
    
    // 显示选择框
    selectionBox.value.style.display = 'block'
    selectionBox.value.style.left = `${startX.value}px`
    selectionBox.value.style.top = `${startY.value}px`
    
    // 绑定鼠标移动/抬起事件
    rightMenu.addEventListener('mousemove', handleMouseMove)
    rightMenu.addEventListener('mouseup', handleMouseUp)
  }
}

// 检测哪些片段被框选
const handleMouseMove = (event) => {
  const items = document.querySelectorAll('.phase_box')
  const selectedRect = selectionBox.value.getBoundingClientRect()
  
  items.forEach((item, index) => {
    const rect = item.getBoundingClientRect()
    const isInSelectionBox = 
      rect.left < selectedRect.right && 
      rect.right > selectedRect.left &&
      rect.top < selectedRect.bottom && 
      rect.bottom > selectedRect.top
      
    if (isInSelectionBox) {
      selectedItems.value.push({ ...item, domIndex: index })
    }
  })
}

// 批量删除
const deleteFragment = () => {
  if (selectedItems.value.length > 0) {
    const indexArray = selectedItems.value.map(item => item.domIndex)
    editVideoStore().batchDeleteFragment(indexArray)
  } else {
    editVideoStore().deleteFragment(timeLineIndex.value)
  }
}

分割操作

typescript 复制代码
const handleSegmentation = () => {
  const timeline = timelineData.value
  const { index } = getIndex() // 获取当前时间点所在的片段索引
  
  if (index >= 0) {
    // 检查分割点是否在片段边界上
    if (timeline[index].timeLineStart_ts === canvasCurrentTime.value ||
        timeline[index].timeLineEnd_ts === canvasCurrentTime.value) {
      message.warning('当前位置无法分割')
      return
    }
    
    // 调用 Store 方法分割片段
    editVideoStore().setSplitInTwo(index, currentTime.value, canvasCurrentTime.value)
  }
}

指示器位置更新

typescript 复制代码
// 初始化指示器位置
const initGrabbing = (offsetX: number = 0) => {
  const canvasBox = videoCanvas.value
  let left = offsetX ? offsetX : Math.round(canvasCurrentTime.value * zoomPX.value - moveLeft.value)
  
  // 边界处理
  if (left < -8 || left > canvasBox?.clientWidth) {
    grabbingStatus.value = false
  } else {
    grabbingStatus.value = true
  }
  
  // 吸附到最后一个片段
  if (canvasCurrentTime.value > duration.value) {
    left = duration.value * zoomPX.value - moveLeft.value
    const index = timelineData.value.length - 1
    sendCurrentTime(timelineData.value[index].end_ts)
  }
  
  grabbingLeft.value = left + 160  // 偏移左侧标题栏宽度
}

timelinetsToVideoTs 将时间轴时间转换为原视频时间

typescript 复制代码
const timelinetsToVideoTs = (timelineTs: number, ranges: Array<ClipFragment>) => {
  // 遍历所有片段,找到当前时间点所在片段
  for (let i = 0; i < ranges.length; i++) {
    // 如果当前时间轴时间大于当前片段的持续时间
    // 说明当前时间点在后边的片段中,需要减去当前片段的时长继续查找
    if (timelineTs > ranges[i].timeLineEnd_ts - ranges[i].timeLineStart_ts) {
      timelineTs -= ranges[i].timeLineEnd_ts - ranges[i].timeLineStart_ts
    } else {
      // 找到了所在片段,计算对应的原视频时间
      return ranges[i].start_ts + timelineTs
    }
  }
}

数据关系:

转换示例:

typescript 复制代码
const ranges = [
  { start_ts: 0, end_ts: 30, timeLineStart_ts: 0, timeLineEnd_ts: 30 },    // 片段A
  { start_ts: 40, end_ts: 60, timeLineStart_ts: 30, timeLineEnd_ts: 50 },   // 片段B
  { start_ts: 75, end_ts: 90, timeLineStart_ts: 50, timeLineEnd_ts: 65 }    // 片段C
]
typescript 复制代码
// 例1: 时间轴时间 10s → 在原视频中对应 10s
timelinetsToVideoTs(10, ranges)
// 循环:
// i=0: 10 > 30? false → 返回 0 + 10 = 10 ✅

// 例2: 时间轴时间 35s → 在原视频中对应 45s
timelinetsToVideoTs(35, ranges)
// 循环:
// i=0: 35 > 30? true → timelineTs = 35 - 30 = 5
// i=1: 5 > 20? false → 返回 40 + 5 = 45 ✅

// 例3: 时间轴时间 55s → 在原视频中对应 80s
timelinetsToVideoTs(55, ranges)
// 循环:
// i=0: 55 > 30? true → timelineTs = 55 - 30 = 25
// i=1: 25 > 20? true → timelineTs = 25 - 20 = 5
// i=2: 5 > 15? false → 返回 75 + 5 = 80 ✅

重点!!!!!!数据管理 Store 实现代码(editVideoStore)

独立ts文件

数据结构说明:

typescript 复制代码
// 单个片段数据结构
interface ClipFragment {
  start_ts: number          // 原视频起始时间(秒)
  end_ts: number            // 原视频结束时间(秒)
  timeLineStart_ts?: number // 时间轴起始位置(计算属性)
  timeLineEnd_ts?: number   // 时间轴结束位置(计算属性)
  from_ai?: boolean         // 是否来自AI识别
  color?: string            // 片段颜色
  name?: string             // 片段名称
  id?: string | null        // 片段ID
}

// 历史记录结构
clipList = [
  [片段1, 片段2, 片段3],  // 初始状态
  [片段1, 片段2],         // 撤销/重做状态1
  [片段1, 片段2, 片段4],  // 撤销/重做状态2
  // ...
]
typescript 复制代码
import { defineStore } from 'pinia'
import { ref } from 'vue'

const editVideoStore = defineStore('editVideoFragment', () => {
  const clipList = ref<Array<any>>([]) // 剪辑片段数组
  const currentIndex = ref<number>(0) // 当前数据的下标
  const clipLength = ref<number>(10) // 数据最大长度
  const currentClipList = ref<Array<any>>([]) // 当前数据
  const timeLineCurrentTime = ref<number>(0) // 当前时间轴时间点
  const recentlySubmittedData = ref<any>([]) // 上次提交保存的数据
  const selectIndex = ref<number>(-1)
  /**
   * Set
   */
  // 撤销
  const undo = () => {
    if (currentIndex.value > 0) {
      currentIndex.value -= 1
      currentClipList.value = getCurrentIndexClipList(currentIndex.value)
      return true
    } else {
      return false
    }
  }
  // 重做
  const redo = () => {
    if (currentIndex.value < clipList.value.length - 1) {
      currentIndex.value += 1
      currentClipList.value = getCurrentIndexClipList(currentIndex.value)
      return true
    } else {
      return false
    }
  }
  // 重命名
  const renameFragment = (index: number, name: string) => {
    if (index >= 0 && index <= currentClipList.value.length - 1) {
      const newClipList = JSON.parse(JSON.stringify(currentClipList.value))
      newClipList[index].name = name
      newClipList[index].from_ai = false // 更改了该ai阶段则变更为false
      addClip(newClipList)
    }
  }
  // 删除
  const deleteFragment = (index: number) => {
    if (index >= 0 && index <= currentClipList.value.length - 1) {
      const newClipList = JSON.parse(JSON.stringify(currentClipList.value))
      // 更新原数组
      newClipList.splice(index, 1)
      addClip(newClipList)
      return true
    } else {
      return false
    }
  }
  // 批量删除
  const batchDeleteFragment = (indexArr: Array<0>) => {
    if (indexArr && indexArr.length > 0) {
      const clipList = JSON.parse(JSON.stringify(currentClipList.value))
      // 更新原数组
      const newClipList = clipList.filter((_, index) => !indexArr.includes(index))
      addClip(newClipList)
      return true
    } else {
      return false
    }
  }

  // 分割 index:分割对象下标,time:分割时间,threshold:临近边缘的阈值
  const setSplitInTwo = (index: number, time: number, threshold: number = 1) => {
    if (index < 0 || index >= currentClipList.value.length || time === 0) {
      return false
    }
    const item = currentClipList.value[index]
    const newClipList = JSON.parse(JSON.stringify(currentClipList.value))
    // 检查时间点是否临近边缘
    // if (Math.abs(time - item.start_ts) < threshold || Math.abs(item.end_ts - time) < threshold) {
    //   return false
    // }
    if (item.start_ts === time || item.end_ts === time) {
      return false
    }
    // 创建两个新的对象
    const firstPart = {
      ...item,
      from_ai: false, // 更改了该ai阶段则变更为false
      end_ts: time // 更新 end_ts 为分割时间
    }
    const secondPart = {
      ...item,
      from_ai: false,
      start_ts: time, // 更新 start_ts 为分割时间
      id: null
    }
    // 更新原数组
    newClipList.splice(index, 1, firstPart, secondPart)
    addClip(newClipList)
    return true
  }
  // 添加片段
  const addClip = (fragmentData: Array<object>) => {
    // 如果数据没有变化则不变化
    if (JSON.stringify(fragmentData) != JSON.stringify(currentClipList.value)) {
      // 如果当前索引不是最后一个,移除后面的数据-更新撤销后不重做直接新增的情况
      if (currentIndex.value < clipList.value.length - 1) {
        clipList.value = clipList.value.slice(0, currentIndex.value + 1)
      }
      // 如果当前数据长度已达到最大长度则把数组前一项移除
      if (clipList.value.length === clipLength.value) {
        clipList.value.shift()
      }
      // 添加新的全量剪辑数据
      clipList.value.push(fragmentData)
      // 更新当前索引
      currentIndex.value = clipList.value.length - 1
      currentClipList.value = getCurrentIndexClipList(currentIndex.value)
      return true
    } else {
      return false
    }
  }
  // 更改最大长度
  const setClipLength = (size: number) => {
    clipLength.value = size
  }
  // 重置/修改全部剪辑数组
  const setAllClipList = (list: Array<any> = []) => {
    clipList.value = list
  }
  // 重置数据状态
  const resetAllDataStatus = () => {
    clipList.value = []
    currentIndex.value = 0
    currentClipList.value = []
    timeLineCurrentTime.value = 0
  }
  // 修改当前下标
  const setCurrentIndex = (index: number) => {
    currentIndex.value = index
  }
  // 当前时间轴时间点
  const setTimeLineCurrentTime = (time: number) => {
    timeLineCurrentTime.value = time
  }
  // 修改当前片段数据
  const setCurrentClipList = (data: Array<object>) => {
    currentClipList.value = data
  }
  // 上次保存的数据
  const setRecentlySubmittedData = (data: Array<object>) => {
    recentlySubmittedData.value = data
  }
  // 当前时间数组的选中下标
  const setTimeLineSelectIndex = (index: number) => {
    selectIndex.value = index
  }
  /**
   * Get
   */
  // 获取当前片段列表
  const getCurrentIndexClipList = (index: number) => {
    return clipList.value[index]
  }
  // 获取当前片段数组
  const getCurrentClipList = () => {
    return currentClipList.value ?? []
  }
  // 获取当前下标
  const getCurrentIndex = () => {
    return currentIndex.value
  }
  // 获取完整数据长度
  const getClipLength = () => {
    return clipList.value.length
  }
  // 获取完整数据
  const getAllClipList = () => {
    return clipList.value
  }
  // 获取原始数据所有片段的总时长
  const getClipTotalTime = (index: number = -1) => {
    const list = index >= 0 ? getCurrentIndexClipList(index) : currentClipList.value
    const totalDuration = list.reduce((total, { start_ts, end_ts }) => {
      return total + (end_ts - start_ts)
    }, 0)
    return totalDuration
  }
  // 获取当前时间轴时间点
  const getTimeLineCurrentTime = () => {
    return timeLineCurrentTime.value
  }
  // 计算剪辑轴的连续时间段
  const getClippingAxisTimePeriod = (data: Array<object> = currentClipList.value) => {
    const clipList = data
    if (clipList) {
      let timeLineStart_ts = 0
      let timeLineEnd_ts = 0
      return clipList.map((i, index) => {
        const totalTime = i.end_ts - i.start_ts
        timeLineStart_ts = index === 0 ? 0 : timeLineEnd_ts
        timeLineEnd_ts = timeLineStart_ts + totalTime
        return {
          ...i,
          timeLineStart_ts,
          timeLineEnd_ts
        }
      })
    }
  }
  // 上次保存的数据
  const getRecentlySubmittedData = () => {
    return recentlySubmittedData.value
  }
  const getTimeLineSelectIndex = () => {
    return selectIndex.value
  }
  return {
    undo,
    redo,
    renameFragment,
    deleteFragment,
    batchDeleteFragment,
    setSplitInTwo,
    setAllClipList,
    resetAllDataStatus,
    addClip,
    setCurrentClipList,
    setTimeLineCurrentTime,
    setRecentlySubmittedData,
    setTimeLineSelectIndex,
    getCurrentIndexClipList,
    getCurrentClipList,
    getCurrentIndex,
    setCurrentIndex,
    setClipLength,
    getClipLength,
    getAllClipList,
    getClipTotalTime,
    getTimeLineCurrentTime,
    getClippingAxisTimePeriod,
    getRecentlySubmittedData,
    getTimeLineSelectIndex
  }
})
export default editVideoStore

使用示例

typescript 复制代码
import editVideoStore from '@/store/module/editVideoFragment'

// 初始化数据
const store = editVideoStore()
store.setAllClipList([])

// 添加片段
store.addClip([
  { start_ts: 0, end_ts: 10, name: '片段1', color: '#FF6B6B' },
  { start_ts: 15, end_ts: 25, name: '片段2', color: '#4ECDC4' }
])

// 分割片段
store.setSplitInTwo(0, 5)  // 在5秒处分割第一个片段

// 删除片段
store.deleteFragment(1)    // 删除第二个片段

// 撤销/重做
store.undo()  // 回到分割前
store.redo()  // 恢复分割

示例图片

相关推荐
芳心粽伙饭1 小时前
HTML第七章 表格标签
前端·html
吃杠碰小鸡1 小时前
Wps常用功能介绍
前端·wps
恋猫de小郭1 小时前
聊个比较有意思的 Flutter Web 问题
android·前端·flutter
IT_陈寒1 小时前
Redis持久化配置漏了这一步,线上数据丢了5小时
前端·人工智能·后端
梦曦i1 小时前
create-uni-app v1.2.0:交互体验优化
前端·uni-app
Moment1 小时前
太好了!NestJS 12 大版本转向 ESM,新项目默认构建换 Rspack
前端·javascript·后端
Csvn1 小时前
泛型与条件类型:TypeScript 类型体操的核心
前端
涛涛ing1 小时前
一周之内,前端 AI 编程的格局被彻底改写了三次
前端·程序员
fxss1 小时前
免费儿童学习网站
前端