视频混剪-撤销/重做系统

撤销/重做系统:从命令模式到实战

项目:https://github.com/briefness/BaseCut

BaseCut 技术博客第十篇。这篇讲撤销/重做系统的设计与实现------让用户可以放心地"后悔"。

需求分析

撤销/重做和普通操作不同:

对比项 普通操作 可撤销操作
数据流 单向 双向(可逆)
状态追踪 记录历史
用户心理 谨慎操作 放心尝试
实现复杂度 中高

撤销/重做需要支持:

  • 所有编辑操作可逆(片段、特效、动画、项目设置)
  • 连续操作合并(如拖拽位置不产生 100 条历史)
  • 批量操作原子化(如分割片段 = 删除 1 + 添加 2)
  • 快捷键 Ctrl+Z / Ctrl+Shift+Z

架构选择

方案对比

方案 原理 优点 缺点
状态快照 每次操作保存完整状态 实现简单 内存消耗大,难合并
命令模式 只保存操作的增量 内存高效,易扩展 实现复杂度稍高

我们选择命令模式------这也是剪映、Premiere、After Effects 等专业工具的选择。

整体流程

复制代码
用户操作 → Store 公共方法 → HistoryStore → HistoryManager → Command
     ↓           ↓               ↓              ↓            ↓
  点击按钮    addClip()      execute()      压栈/合并    execute()/undo()

核心模块设计

1. 命令接口

typescript 复制代码
// src/engine/HistoryTypes.ts
interface HistoryCommand {
  id: string                    // 唯一标识
  type: string                  // 命令类型(如 'ADD_CLIP')
  description: string           // 描述(用于 UI 显示)
  timestamp: number             // 时间戳
  
  execute(): void               // 执行
  undo(): void                  // 撤销
  
  canMergeWith?(other): boolean // 可选:判断是否可合并
  mergeWith?(other): Command    // 可选:合并命令
}

2. 历史管理器

typescript 复制代码
// src/engine/HistoryManager.ts
class HistoryManager {
  private undoStack: HistoryCommand[] = []
  private redoStack: HistoryCommand[] = []
  private config = { maxStackSize: 100, mergeWindowMs: 500 }
  
  execute(command: HistoryCommand): void {
    // 尝试与栈顶命令合并
    const lastCmd = this.undoStack[this.undoStack.length - 1]
    const canMerge = this.shouldMerge(lastCmd, command)
    
    if (canMerge) {
      const merged = lastCmd.mergeWith!(command)
      this.undoStack[this.undoStack.length - 1] = merged
      merged.execute()
    } else {
      command.execute()
      this.undoStack.push(command)
    }
    
    // 新命令执行后清空重做栈
    this.redoStack = []
    
    // 限制栈深度
    if (this.undoStack.length > this.config.maxStackSize) {
      this.undoStack.shift()
    }
  }
  
  undo(): boolean {
    const cmd = this.undoStack.pop()
    if (!cmd) return false
    
    cmd.undo()
    this.redoStack.push(cmd)
    return true
  }
  
  redo(): boolean {
    const cmd = this.redoStack.pop()
    if (!cmd) return false
    
    cmd.execute()
    this.undoStack.push(cmd)
    return true
  }
}

3. 双栈结构图解

复制代码
[执行 A] → undoStack: [A]       redoStack: []
[执行 B] → undoStack: [A, B]    redoStack: []
[撤销]   → undoStack: [A]       redoStack: [B]
[撤销]   → undoStack: []        redoStack: [B, A]
[重做]   → undoStack: [A]       redoStack: [B]
[执行 C] → undoStack: [A, C]    redoStack: []  ← 分支清空!

命令实现

片段命令

typescript 复制代码
// src/engine/commands/TimelineCommands.ts

// 添加片段
class AddClipCommand implements HistoryCommand {
  private createdClip: Clip | null = null
  
  constructor(
    private getStore: () => TimelineStore,
    private trackId: string,
    private clipData: Omit<Clip, 'id'>
  ) {}
  
  execute(): void {
    const store = this.getStore()
    if (this.createdClip) {
      // 重做:恢复完整数据
      store._addClipDirect(this.trackId, this.createdClip)
    } else {
      // 首次执行:保存创建结果
      const clip = store._addClipDirect(this.trackId, this.clipData)
      this.createdClip = { ...clip }
    }
  }
  
  undo(): void {
    if (this.createdClip) {
      this.getStore()._removeClipDirect(this.createdClip.id)
    }
  }
}

// 更新片段(支持合并)
class UpdateClipCommand implements HistoryCommand {
  private oldValues: Partial<Clip> | null = null
  
  constructor(
    private getStore: () => TimelineStore,
    private clipId: string,
    private updates: Partial<Clip>
  ) {}
  
  execute(): void {
    const store = this.getStore()
    const clip = store.getClipById(this.clipId)
    
    // 首次执行时保存旧值
    if (clip && !this.oldValues) {
      this.oldValues = {}
      for (const key of Object.keys(this.updates)) {
        this.oldValues[key] = clip[key]
      }
    }
    
    store._updateClipDirect(this.clipId, this.updates)
  }
  
  undo(): void {
    if (this.oldValues) {
      this.getStore()._updateClipDirect(this.clipId, this.oldValues)
    }
  }
  
  // 连续更新同一片段时合并
  canMergeWith(other: HistoryCommand): boolean {
    return other instanceof UpdateClipCommand && other.clipId === this.clipId
  }
  
  mergeWith(other: HistoryCommand): HistoryCommand {
    const otherCmd = other as UpdateClipCommand
    const merged = new UpdateClipCommand(
      this.getStore,
      this.clipId,
      { ...this.updates, ...otherCmd.updates }
    )
    merged.oldValues = this.oldValues  // 保留最初的旧值
    return merged
  }
}

分割片段命令(复杂操作)

typescript 复制代码
class SplitClipCommand implements HistoryCommand {
  private originalClip: Clip | null = null
  private splitClipIds: [string, string] | null = null
  
  constructor(
    private getStore: () => TimelineStore,
    private clipId: string,
    private splitTime: number
  ) {}
  
  execute(): void {
    const store = this.getStore()
    
    // 保存原始片段
    if (!this.originalClip) {
      const clip = store.getClipById(this.clipId)
      if (clip) this.originalClip = { ...clip }
    }
    
    // 执行分割
    const result = store._splitClipDirect(this.clipId, this.splitTime)
    if (result) {
      this.splitClipIds = [result[0].id, result[1].id]
    }
  }
  
  undo(): void {
    const store = this.getStore()
    
    // 删除分割后的两个片段
    if (this.splitClipIds) {
      store._removeClipDirect(this.splitClipIds[0])
      store._removeClipDirect(this.splitClipIds[1])
    }
    
    // 恢复原始片段
    if (this.originalClip) {
      store._addClipDirect(this.originalClip.trackId, this.originalClip)
    }
  }
}

Store 集成

方法分离策略

每个 Store 分离公共方法直接方法

typescript 复制代码
// src/stores/timeline.ts
export const useTimelineStore = defineStore('timeline', () => {
  
  // 直接方法(供命令内部调用,不记录历史)
  function _addClipDirect(trackId: string, clip: ClipData): Clip {
    // 直接修改状态
    const newClip = { ...clip, id: crypto.randomUUID() }
    tracks.value.find(t => t.id === trackId)?.clips.push(newClip)
    return newClip
  }
  
  // 公共方法(用户调用,记录历史)
  function addClip(trackId: string, clip: ClipData): Clip {
    const command = new AddClipCommand(getThisStore, trackId, clip)
    useHistoryStore().execute(command)
    return /* 返回新创建的片段 */
  }
  
  return {
    addClip,           // 公开:用户调用
    _addClipDirect,    // 公开:命令调用
  }
})

已集成的 Store

Store 包装的方法
timeline.ts addClip, removeClip, updateClip, moveClip, splitClip, addTrack, removeTrack
effects.ts addEffect, removeEffect, updateEffect, toggleEffect, reorderEffects
animation.ts addKeyframe, removeKeyframe, updateKeyframe
project.ts setCanvasSize, setFrameRate, rename

Pinia History Store

typescript 复制代码
// src/stores/history.ts
export const useHistoryStore = defineStore('history', () => {
  const manager = new HistoryManager()
  
  // 响应式状态
  const canUndo = ref(false)
  const canRedo = ref(false)
  const undoDescription = ref('')
  const redoDescription = ref('')
  
  function execute(command: HistoryCommand) {
    manager.execute(command)
    syncState()
  }
  
  function undo() {
    if (manager.undo()) syncState()
  }
  
  function redo() {
    if (manager.redo()) syncState()
  }
  
  function syncState() {
    canUndo.value = manager.canUndo
    canRedo.value = manager.canRedo
    undoDescription.value = manager.undoDescription
    redoDescription.value = manager.redoDescription
  }
  
  return { canUndo, canRedo, undoDescription, redoDescription, execute, undo, redo }
})

快捷键集成

typescript 复制代码
// src/stores/history.ts
function initKeyboardShortcuts() {
  window.addEventListener('keydown', handleKeydown)
}

function handleKeydown(e: KeyboardEvent) {
  const isMac = navigator.platform.includes('Mac')
  const modKey = isMac ? e.metaKey : e.ctrlKey
  
  if (!modKey) return
  
  if (e.key === 'z' && !e.shiftKey) {
    e.preventDefault()
    undo()
  } else if ((e.key === 'z' && e.shiftKey) || e.key === 'y') {
    e.preventDefault()
    redo()
  }
}

// App.vue
onMounted(() => {
  historyStore.initKeyboardShortcuts()
})

命令合并策略

为什么需要合并?

拖拽片段位置时,每帧都可能触发 updateClip,如果不合并:

复制代码
拖拽 1 秒 @ 60fps = 60 条历史记录 😱

合并窗口

typescript 复制代码
// 500ms 内的同类型命令可合并
private shouldMerge(lastCmd: HistoryCommand, newCmd: HistoryCommand): boolean {
  if (!lastCmd?.canMergeWith?.(newCmd)) return false
  
  const timeDelta = newCmd.timestamp - lastCmd.timestamp
  return timeDelta < this.config.mergeWindowMs
}

合并后的历史

复制代码
拖拽开始 → 创建 UpdateClipCommand { oldX: 0 }
拖拽中   → 合并 { oldX: 0, newX: 50 }
拖拽中   → 合并 { oldX: 0, newX: 100 }
拖拽结束 → 最终只有 1 条记录 { oldX: 0, newX: 100 } ✅

性能优化

1. 惰性获取 Store

避免循环依赖,命令中通过函数获取 Store:

typescript 复制代码
class AddClipCommand {
  constructor(
    private getStore: () => TimelineStore,  // 惰性获取
    ...
  ) {}
  
  execute(): void {
    const store = this.getStore()  // 调用时才获取
    ...
  }
}

2. 栈深度限制

typescript 复制代码
execute(command: HistoryCommand): void {
  ...
  // 限制最大 100 步历史
  if (this.undoStack.length > 100) {
    this.undoStack.shift()  // 移除最旧的
  }
}

3. 深拷贝数据

typescript 复制代码
// ❌ 错误:引用会被后续修改
this.oldClip = clip

// ✅ 正确:必须深拷贝
this.oldClip = { 
  ...clip, 
  effects: clip.effects.map(e => ({ ...e }))
}

最终效果

功能 快捷键
撤销 Ctrl+Z / Cmd+Z
重做 Ctrl+Shift+Z / Cmd+Shift+Z / Ctrl+Y

支持的操作类型:

  • ✅ 片段操作(添加、删除、更新、移动、分割)
  • ✅ 轨道操作(添加、删除、静音、锁定)
  • ✅ 特效操作(添加、删除、更新、切换、排序)
  • ✅ 动画操作(添加、删除、更新关键帧)
  • ✅ 项目设置(画布尺寸、帧率、名称)

系列目录

  • 技术选型与项目结构
  • 时间轴数据模型
  • WebGL 渲染与滤镜
  • 转场动画实现
  • WebCodecs 视频导出
  • LeaferJS 贴纸系统
  • 视频特效系统
  • 关键帧动画系统
  • 性能优化专题
  • 撤销/重做系统(本文)
相关推荐
小猴子爱上树2 小时前
一站式解决图片视频翻译难题的高效AI工具
人工智能·python·音视频
EasyGBS4 小时前
ONVIF设备接入国标GB28181视频平台EasyGBS:不用国标也能一键拉流!
服务器·php·音视频
Legendary_0085 小时前
LDR6500 设计指南:Type-C 音频快充二合一配件 PD 取电与插拔时序控制方案
c语言·开发语言·音视频
音视频牛哥7 小时前
SmartMediaKit 推拉流MP4录像实践:实时传输与本地留存一体化
音视频·rtmp推流录像·rtmp播放端录像·rtsp播放端录像·rtsp流录制·rtmp流录制·rtsp服务器录像
石臻臻的杂货铺8 小时前
做 AI 视频,先让 Blender 把镜头演一遍
人工智能·音视频·blender
ALINX技术博客8 小时前
【黑金云课堂】FPGA技术教程Vitis开发:SD卡WAV文件读取音频播放实验
fpga开发·音视频
沐禾安信1 天前
AVI 视频打不开?2026 四款转 MP4 工具封神
音视频·电脑录屏
兵叔物联1 天前
基于FFmpeg的短视频自动剪辑工具
ffmpeg·音视频
AORUO奥偌1 天前
从“拼凑”到“集成”:音视频系统如何重塑弱电智能化建设逻辑
音视频·公共广播系统·音视频系统·多媒体会议与ip广播系统·多媒体会议系统
禹亮科技1 天前
大型国企会议室音视频集成实战:3间120㎡空间Poly G7500+LED大屏+讯飞多语言会议系统落地方案
音视频·视频会议系统集成·上海禹亮科技·讯飞听见多语言会议系统·poly g7500