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

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

项目: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 贴纸系统
  • 视频特效系统
  • 关键帧动画系统
  • 性能优化专题
  • 撤销/重做系统(本文)
相关推荐
心动啊12118 小时前
简单了解下音频和VAD
音视频
程序员哈基耄19 小时前
小红书在线去水印工具:一键下载高清无水印图片与视频
音视频
科技小E19 小时前
EasyGBS算法算力平台重构服务业视频监控AI应用
人工智能·重构·音视频
彷徨而立20 小时前
【Windows API】音频 API 对比:wavein/waveout、DirectSound、ASIO、WASAPI
windows·音视频
小咖自动剪辑20 小时前
小咖批量剪辑助手:视频批量自动剪辑与混剪处理软件(Windows)
人工智能·实时互动·音视频·语音识别·视频编解码
努力犯错20 小时前
LTX-2 进阶 Prompt 技巧:从入门到专业视频创作
人工智能·数码相机·机器学习·计算机视觉·开源·prompt·音视频
百锦再20 小时前
AI视频生成模型从无到有:构建、实现与调试完全指南
人工智能·python·ai·小程序·aigc·音视频·notepad++
Android系统攻城狮1 天前
Android16音频之获取录制状态AudioRecord.getRecordingState:用法实例(一百七十六)
音视频·android16·音频进阶
天天进步20151 天前
KrillinAI 源码级深度拆解二:时间轴的艺术:深入 KrillinAI 的字幕对齐与音频切分算法
算法·音视频