视频混剪-关键帧动画

关键帧动画系统:从插值到矩阵变换

BaseCut 技术博客第八篇。这篇讲关键帧动画系统的设计与实现------如何让视频动起来。

需求分析

关键帧动画和静态属性调整不同:

对比项 静态属性 关键帧动画
时间依赖 是(随时间变化)
参数变化 整个片段固定 在时间点之间平滑过渡
用户操作 设置一次 在多个时间点设置值
渲染方式 固定变换 实时计算插值

关键帧动画需要支持:

  • 位置、缩放、旋转、透明度动画
  • 多种缓动曲线(线性、缓入缓出、弹性等)
  • 预览和导出渲染一致性
  • 专业级的矩阵变换

架构设计

整体流程

复制代码
用户操作 → AnimationStore → AnimationEngine → WebGLRenderer
     ↓           ↓                ↓                ↓
  设置关键帧   存储动画数据    计算插值/矩阵    应用变换渲染

核心模块

typescript 复制代码
// 1. 类型定义
interface Keyframe {
  id: string
  time: number           // 相对于片段起点的时间(秒)
  value: number          // 属性值
  easing: EasingType     // 缓动类型
}

interface AnimationTrack {
  property: AnimatableProperty  // 'position.x' | 'scale' | 'rotation' | 'opacity'
  keyframes: Keyframe[]
  enabled: boolean
}

// 2. 动画引擎
function getAnimatedTransform(tracks: AnimationTrack[], time: number): AnimatedTransform
function createTransformMatrix(transform: AnimatedTransform, canvasSize): Float32Array

// 3. 状态管理
const animationStore = defineStore('animation', () => {
  const clipAnimations = reactive<Map<string, ClipAnimation>>()
  // CRUD 操作
})

关键帧插值

核心算法

typescript 复制代码
function interpolateValue(keyframes: Keyframe[], time: number): number {
  // 排序确保时间顺序
  const sorted = [...keyframes].sort((a, b) => a.time - b.time)
  
  // 边界情况:时间在第一个关键帧之前
  if (time <= sorted[0].time) {
    return sorted[0].value
  }
  
  // 边界情况:时间在最后一个关键帧之后
  if (time >= sorted[sorted.length - 1].time) {
    return sorted[sorted.length - 1].value
  }
  
  // 找到当前时间所在的区间
  for (let i = 0; i < sorted.length - 1; i++) {
    const curr = sorted[i]
    const next = sorted[i + 1]
    
    if (time >= curr.time && time <= next.time) {
      // 计算进度 (0~1)
      const progress = (time - curr.time) / (next.time - curr.time)
      
      // 应用缓动函数
      const easedProgress = applyEasing(progress, next.easing)
      
      // 线性插值
      return curr.value + (next.value - curr.value) * easedProgress
    }
  }
  
  return sorted[0].value
}

行业标准行为

关键帧边界处理遵循 After Effects / 剪映 的标准:

复制代码
时间线:  0s ──── 3s ──── 5s ──── 10s
          │       ◆       ◆       │
          │    X=100   X=0        │
          │       │       │       │
         100     100 ─→ 0        0
         (保持)  (过渡)   (保持)
  • 第一个关键帧之前 → 保持第一个关键帧的值
  • 两个关键帧之间 → 根据缓动曲线插值过渡
  • 最后一个关键帧之后 → 保持最后一个关键帧的值

缓动函数实现

基础缓动

typescript 复制代码
const easingFunctions = {
  // 线性
  linear: (t: number) => t,
  
  // 二次方
  easeIn: (t: number) => t * t,
  easeOut: (t: number) => t * (2 - t),
  easeInOut: (t: number) => t < 0.5 
    ? 2 * t * t 
    : -1 + (4 - 2 * t) * t,
  
  // 三次方
  easeInCubic: (t: number) => t * t * t,
  easeOutCubic: (t: number) => (--t) * t * t + 1,
  
  // 回弹
  easeInBack: (t: number) => {
    const c = 1.70158
    return t * t * ((c + 1) * t - c)
  },
  easeOutBack: (t: number) => {
    const c = 1.70158
    return 1 + (--t) * t * ((c + 1) * t + c)
  },
  
  // 弹性
  easeOutElastic: (t: number) => {
    if (t === 0 || t === 1) return t
    return Math.pow(2, -10 * t) * Math.sin((t - 0.1) * 5 * Math.PI) + 1
  }
}

自定义贝塞尔曲线

typescript 复制代码
function cubicBezier(
  t: number,
  p1x: number, p1y: number,
  p2x: number, p2y: number
): number {
  // 牛顿迭代法求解
  let x = t
  for (let i = 0; i < 8; i++) {
    const currentX = bezierX(x, p1x, p2x) - t
    if (Math.abs(currentX) < 0.001) break
    const derivativeX = bezierDerivativeX(x, p1x, p2x)
    x -= currentX / derivativeX
  }
  return bezierY(x, p1y, p2y)
}

矩阵变换

为什么需要矩阵?

多个变换需要按顺序组合

复制代码
错误:先移动再缩放 ≠ 先缩放再移动

正确:使用变换矩阵,统一处理

4x4 变换矩阵

typescript 复制代码
function createTransformMatrix(
  transform: AnimatedTransform,
  canvasWidth: number,
  canvasHeight: number
): Float32Array {
  const matrix = new Float32Array(16)
  
  // 1. 单位矩阵
  mat4.identity(matrix)
  
  // 2. 平移(转换为 NDC 坐标)
  const tx = transform.position.x / canvasWidth * 2
  const ty = -transform.position.y / canvasHeight * 2
  mat4.translate(matrix, matrix, [tx, ty, 0])
  
  // 3. 旋转(弧度)
  const radians = transform.rotation * Math.PI / 180
  mat4.rotateZ(matrix, matrix, radians)
  
  // 4. 缩放
  mat4.scale(matrix, matrix, [transform.scale.x, transform.scale.y, 1])
  
  return matrix
}

锚点处理

变换需要围绕锚点进行:

typescript 复制代码
// 完整的变换顺序
// 1. 移动到锚点
mat4.translate(matrix, matrix, [-anchorX, -anchorY, 0])
// 2. 缩放
mat4.scale(matrix, matrix, [scaleX, scaleY, 1])
// 3. 旋转
mat4.rotateZ(matrix, matrix, rotation)
// 4. 移回原位
mat4.translate(matrix, matrix, [anchorX, anchorY, 0])
// 5. 最终位移
mat4.translate(matrix, matrix, [posX, posY, 0])

WebGL 动画着色器

顶点着色器

glsl 复制代码
attribute vec2 a_position;
attribute vec2 a_texCoord;

uniform mat4 u_matrix;      // 变换矩阵
uniform mat4 u_projection;  // 投影矩阵

varying vec2 v_texCoord;

void main() {
  // 应用变换矩阵
  gl_Position = u_projection * u_matrix * vec4(a_position, 0.0, 1.0);
  v_texCoord = a_texCoord;
}

片段着色器

glsl 复制代码
precision mediump float;

uniform sampler2D u_texture;
uniform float u_opacity;  // 透明度动画

varying vec2 v_texCoord;

void main() {
  vec4 color = texture2D(u_texture, v_texCoord);
  
  // 应用透明度
  color.a *= u_opacity;
  
  // 预乘 Alpha
  color.rgb *= color.a;
  
  gl_FragColor = color;
}

渲染集成

预览播放

typescript 复制代码
// Player.vue - renderCurrentFrame
async function renderCurrentFrame() {
  const animation = animationStore.getClipAnimation(videoClip.id)
  
  // 计算片段内时间
  const timeInClip = currentTime - clip.startTime
  
  if (animation && hasActiveKeyframes(animation)) {
    // 计算动画变换
    const animTransform = getAnimatedTransform(animation.tracks, timeInClip)
    const matrix = createTransformMatrix(animTransform, canvasWidth, canvasHeight)
    
    // 使用动画渲染器
    renderer.renderFrameWithAnimation(videoElement, matrix, animTransform.opacity)
  } else {
    // 普通渲染
    renderer.renderFrame(videoElement)
  }
}

视频导出

typescript 复制代码
// WebCodecsExporter.ts
async function exportClip(clip: WebCodecsExportClip) {
  if (clip.animation && hasActiveKeyframes(clip.animation)) {
    const animTransform = getAnimatedTransform(
      clip.animation.tracks,
      timeInClip
    )
    const matrix = createTransformMatrix(animTransform, width, height)
    
    renderer.renderFrameWithAnimation(frame, matrix, animTransform.opacity)
  }
}

状态管理

Pinia Store 设计

typescript 复制代码
export const useAnimationStore = defineStore('animation', () => {
  // 响应式存储
  const clipAnimations = reactive<Map<string, ClipAnimation>>(new Map())
  
  // 添加关键帧
  function addKeyframe(
    clipId: string,
    property: AnimatableProperty,
    data: Partial<Keyframe>
  ): Keyframe {
    ensureTrackExists(clipId, property)
    
    const keyframe: Keyframe = {
      id: crypto.randomUUID(),
      time: data.time ?? 0,
      value: data.value ?? getDefaultValue(property),
      easing: data.easing ?? 'easeInOut'
    }
    
    const track = getTrack(clipId, property)!
    track.keyframes.push(keyframe)
    
    // 保持排序
    track.keyframes.sort((a, b) => a.time - b.time)
    
    return keyframe
  }
  
  // 更新关键帧
  function updateKeyframe(
    clipId: string,
    property: AnimatableProperty,
    keyframeId: string,
    updates: Partial<Keyframe>
  ) {
    const track = getTrack(clipId, property)
    if (!track) return
    
    const keyframe = track.keyframes.find(kf => kf.id === keyframeId)
    if (keyframe) {
      Object.assign(keyframe, updates)
      
      // 时间变化需要重新排序
      if (updates.time !== undefined) {
        track.keyframes.sort((a, b) => a.time - b.time)
      }
    }
  }
  
  return { clipAnimations, addKeyframe, updateKeyframe, ... }
})

UI 设计

属性面板结构

复制代码
┌─────────────────────────────────────┐
│ 🎬 关键帧动画           ⏱️ 00:02.89 │
├─────────────────────────────────────┤
│ ▼ 📍 位置                           │
│   ◆ X  [  100.00  ] px  ◀ ▶        │
│   ◇ Y  [    0.00  ] px  ◀ ▶        │
├─────────────────────────────────────┤
│ ▶ 🔍 缩放                           │
│ ▶ 🔄 旋转                           │
│ ▶ 💧 透明度                         │
└─────────────────────────────────────┘

◆ = 当前时间有关键帧(黄色)
◇ = 当前时间无关键帧(灰色)
◀ ▶ = 跳转到上/下一个关键帧

输入确认逻辑

为了更好的用户体验,输入值只在失去焦点按 Enter 时才提交:

typescript 复制代码
const localInputValue = ref(displayValue.value)
const isEditing = ref(false)

function onInputFocus() {
  isEditing.value = true
}

function commitValue() {
  isEditing.value = false
  
  const actualValue = parseFloat(localInputValue.value)
  if (isNaN(actualValue)) {
    localInputValue.value = displayValue.value
    return
  }
  
  // 有关键帧则更新,无则创建
  if (hasKeyframeAtCurrentTime) {
    updateKeyframe(clipId, property, currentKeyframe.id, { value: actualValue })
  } else {
    addKeyframe(clipId, property, { time: currentTime, value: actualValue })
  }
}

支持的属性

属性 类型 范围 默认值
position.x 位置 X -10000 ~ 10000 px 0
position.y 位置 Y -10000 ~ 10000 px 0
scale.x 缩放 X 0 ~ 10 1
scale.y 缩放 Y 0 ~ 10 1
scale 统一缩放 0 ~ 10 1
rotation 旋转 -360° ~ 360° 0
opacity 透明度 0 ~ 1 1
anchor.x 锚点 X 0 ~ 1 0.5
anchor.y 锚点 Y 0 ~ 1 0.5

性能优化

1. 惰性计算

只有动画片段才计算变换:

typescript 复制代码
if (animation && animation.tracks.some(t => t.enabled && t.keyframes.length > 0)) {
  // 有动画,计算变换
} else {
  // 无动画,直接渲染
}

2. 矩阵缓存

同一帧内多次渲染可复用矩阵:

typescript 复制代码
const matrixCache = new Map<string, Float32Array>()

function getCachedMatrix(clipId: string, time: number): Float32Array {
  const key = `${clipId}-${time.toFixed(3)}`
  if (matrixCache.has(key)) {
    return matrixCache.get(key)!
  }
  // 计算并缓存
}

3. 关键帧预排序

添加关键帧时立即排序,而不是每次插值时排序:

typescript 复制代码
function addKeyframe(keyframe: Keyframe) {
  track.keyframes.push(keyframe)
  track.keyframes.sort((a, b) => a.time - b.time)  // 一次排序
}

下一篇

本系列已完结。完整目录:

  1. 技术选型与项目结构
  2. 时间轴数据模型
  3. WebGL 渲染与滤镜
  4. 转场动画实现
  5. WebCodecs 视频导出
  6. LeaferJS 贴纸系统
  7. 视频特效系统
  8. 关键帧动画系统(本文)
相关推荐
小咖自动剪辑17 小时前
免费超强图片压缩工具:批量操作 + 高效传输不失真
人工智能·音视频·语音识别·实时音视频·视频编解码
BitaHub202417 小时前
文献分享 | Audio Flamingo 3:打造全开源音频智能新标杆
音视频
Facechat18 小时前
视频混剪-性能优化
性能优化·音视频
TEL1892462247718 小时前
IT6251FN:LVDS转DisplayPort 1.1a发射机
音视频·实时音视频·视频编解码
WX1316951899818 小时前
音频分析仪APX525 APX515 APX528 APX526测试参数
科技·音视频·信息与通信
Facechat19 小时前
视频混剪-特效篇
音视频
de之梦-御风19 小时前
【视频投屏】最小可用(MVP)局域网投屏”开源项目架构
架构·开源·音视频
努力犯错19 小时前
如何在ComfyUI中配置LTX-2:2026年AI视频生成完整指南
大数据·人工智能·计算机视觉·语言模型·开源·音视频
玖日大大19 小时前
Wan2.1视频生成模型本地部署完整指南
人工智能·音视频