程序员都是这样剪视频的?

0 阅读13分钟

说在前面

🎨 最近网上有这样一个很火的图片踩点效果:一张照片一开始是黑白的,然后从某个点开始,颜色像涟漪一样向外扩散,慢慢地整张图片就变成了彩色,配上音乐节拍,一张接一张地上色,视觉冲击力拉满。

效果很有趣但是我又懒得手动去剪辑,所以就写了个工具网站来一键生成

今天就来给大家分享一下这个工具,拆解一下具体效果是怎么实现的~

效果展示

主要特性:

  • 🎯 点击画布任意位置设置扩散起点,支持多个扩散点
  • 🌊 扩散边缘带有涟漪光波效果,像水波纹一样
  • 🏀 可选弹跳小球动画,小球在相邻图片的扩散点之间跳跃
  • 🎵 支持音乐踩点模式,每到一个节拍自动切换下一张图片
  • ⚙️ 扩散时长、边缘柔和度、停留时间等参数可调
  • 📹 支持导出 WebM 视频

在线体验

在线网站

🔗 在线体验:jyeontu.xyz/flika/#/tra…

桌面版

也提供了桌面版安装包下载,有兴趣的也可以试试,选择合适的版本,下载后直接安装即可

🔗下载地址:github.com/yongtaozhen…

实现思路

整个效果的核心思路其实就一句话:

对每个像素,计算它到最近扩散点的距离;距离小于当前扩散半径的像素显示原色,否则显示灰度。

听起来简单对吧?但要做到丝滑、好看、不卡顿,还得处理不少细节。我们一步一步来~

1、灰度转换——给照片"去色"

第一步是把原始彩色图片转成灰度版本,后面渲染的时候用于"还没扩散到"的区域。

export function toGrayscale(data: Uint8ClampedArray): Uint8ClampedArray {
  const gray = new Uint8ClampedArray(data.length)
  for (let i = 0; i < data.length; i += 4) {
    const lum = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]
    gray[i] = gray[i + 1] = gray[i + 2] = lum
    gray[i + 3] = data[i + 3] // Alpha 通道不变
  }
  return gray
}

这里用的是经典的 ITU-R BT.601 亮度公式

灰度 = 0.299 × R + 0.587 × G + 0.114 × B

为什么不是简单的 (R + G + B) / 3?因为人眼对绿色最敏感,对蓝色最不敏感,加权之后的灰度图看起来更自然

这一步我们直接预计算好整张图片的灰度数据,存成 Uint8ClampedArray,后面渲染时直接查表,不用每帧重复算。

2、距离场计算——效果的核心

距离场(Distance Field)是这个效果的灵魂。简单来说就是:对画布上的每一个像素,算出它到最近那个扩散点的距离

export function computeDistanceField(
  width: number,
  height: number,
  points: { x: number; y: number }[], // 归一化坐标 0~1
): { field: Float32Array; cosAngle: Float32Array; sinAngle: Float32Array; maxDist: number } {
  const field = new Float32Array(width * height)
  const cosAngle = new Float32Array(width * height)
  const sinAngle = new Float32Array(width * height)
  let maxDist = 0

  // 把归一化坐标转成像素坐标
  const pxPoints = points.map((p) => ({ x: p.x * width, y: p.y * height }))

  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      let minDist = Infinity
      let nearDx = 0
      let nearDy = 0

      // 找最近的扩散点
      for (const pt of pxPoints) {
        const dx = x - pt.x
        const dy = y - pt.y
        const dist = Math.sqrt(dx * dx + dy * dy)
        if (dist < minDist) {
          minDist = dist
          nearDx = dx
          nearDy = dy
        }
      }

      const idx = y * width + x
      field[idx] = minDist

      // 顺便记录方向角,后面涟漪波纹要用
      const angle = Math.atan2(nearDy, nearDx)
      cosAngle[idx] = Math.cos(angle)
      sinAngle[idx] = Math.sin(angle)

      if (minDist > maxDist) maxDist = minDist
    }
  }

  return { field, cosAngle, sinAngle, maxDist }
}

来解释一下这段代码做了什么:

(1)双重循环遍历每个像素

对于 1280×720 的画布,那就是 921,600 个像素,每个都要算一遍。听起来很暴力,但这是一次性的预计算,算完之后整个动画播放期间都不用再算了。

(2)对每个像素找最近的扩散点

如果有多个扩散点,用的是最简单的暴力搜索——遍历所有点取最小值。扩散点数量一般就 1~3 个,所以这里不需要什么 KD 树之类的优化。

(3)顺便记录方向角

cosAnglesinAngle 记录了每个像素指向最近扩散点的方向。这个信息后面做涟漪波纹的像素位移时会用到,所以在算距离场的时候一并算了,一石二鸟

(4)记录最大距离

maxDist 是距离场中的最大值,也就是离所有扩散点最远的那个像素的距离。后面渲染时会用它来做归一化:扩散半径 = 进度 × maxDist,当进度到 1 时刚好覆盖整张图。

3、逐帧渲染——让颜色"扩散"起来

有了距离场和灰度数据,就可以逐帧渲染扩散动画了。核心渲染逻辑如下:

// 扩散进度 0~1(随时间推进)
const spreadProgress = Math.min(1, imageTime / effectiveSpreadDuration)
// 当前扩散半径(像素)
const currentRadius = spreadProgress * data.maxDist
// 边缘柔和宽度(用户可调,默认 30px)
const edgeWidth = config.edgeWidth

for (let y = 0; y < height; y++) {
  for (let x = 0; x < width; x++) {
    const idx = y * width + x
    const dist = distanceField[idx]
    const px = idx * 4

    if (dist <= currentRadius - edgeWidth) {
      // 完全在扩散范围内 → 显示原色
      output[px]     = originalData[px]
      output[px + 1] = originalData[px + 1]
      output[px + 2] = originalData[px + 2]
    } else if (dist <= currentRadius) {
      // 在边缘过渡带 → 灰度和原色混合
      const t = smoothstep((currentRadius - dist) / edgeWidth)
      output[px]     = grayscaleData[px]     + (originalData[px]     - grayscaleData[px])     * t
      output[px + 1] = grayscaleData[px + 1] + (originalData[px + 1] - grayscaleData[px + 1]) * t
      output[px + 2] = grayscaleData[px + 2] + (originalData[px + 2] - grayscaleData[px + 2]) * t
    } else {
      // 还没扩散到 → 显示灰度
      output[px]     = grayscaleData[px]
      output[px + 1] = grayscaleData[px + 1]
      output[px + 2] = grayscaleData[px + 2]
    }
    output[px + 3] = originalData[px + 3] // Alpha
  }
}

逻辑用一张图来表示就是:

         ← edgeWidth →
         ┌──────────────┐
─────────┤  过渡混合带    ├──────────────
  原色区  │ (smoothstep) │   灰度区
─────────┤              ├──────────────
         └──────────────┘
        ← currentRadius →

每帧 currentRadius 都在增大(因为 spreadProgress 从 0 涨到 1),所以每帧都有更多的像素"被上色"。

4、Smoothstep——让边缘不生硬

如果直接用线性插值(t = (currentRadius - dist) / edgeWidth),边缘过渡会很硬、很突兀。可以用 smoothstep 函数来让过渡更丝滑:

export function smoothstep(t: number): number {
  const x = Math.max(0, Math.min(1, t))
  return x * x * (3 - 2 * x)
}

smoothstep 的数学公式是 f(x) = 3x² - 2x³,它有一个非常好的特性——在 0 和 1 处的导数都是 0,也就是说起步和收尾都是平滑的,没有突变

对比一下:

插值方式过渡效果
线性 t边缘有明显分界线,过渡不自然
smoothstep 3t²-2t³边缘柔和自然,像水彩晕染

这个函数在图形学里用得非常多,从 shader 到 CSS 动画曲线都能看到它的身影。就三行代码,效果提升巨大。

5、涟漪光波

光有上色扩散还不够,在扩散的前沿加了涟漪波纹效果——扩散边缘的像素会产生类似水波纹的位移,让整个效果更有动感。

// 涟漪常量
const WAVE_COUNT = 3      // 3 层波纹
const WAVE_WIDTH = 60     // 每层波纹宽度 60px
const WAVE_AMPLITUDE = 18 // 最大位移 18px
const WAVE_ZONE = WAVE_WIDTH * WAVE_COUNT // 波纹影响区域 = 180px

// 淡入淡出系数:扩散刚开始和快结束时波纹弱,中间最强
const animFade = Math.sin(spreadProgress * Math.PI)

for (let y = 0; y < height; y++) {
  for (let x = 0; x < width; x++) {
    const idx = y * width + x
    const dist = distanceField[idx]

    let srcX = x  // 实际采样的源坐标
    let srcY = y

    // 只在扩散前沿的 180px 范围内产生波纹
    const relDist = currentRadius - dist
    if (relDist > 0 && relDist < WAVE_ZONE) {
      // 正弦波 × 指数衰减 × 淡入淡出
      const wavePhase = (relDist / WAVE_WIDTH) * Math.PI * 2
      const amplitude = WAVE_AMPLITUDE
        * Math.sin(wavePhase)          // 正弦波形
        * Math.exp(-relDist / WAVE_ZONE * 2)  // 越远衰减越强
        * animFade                     // 整体淡入淡出

      // 沿"像素→扩散点"方向位移
      srcX = x + cosAngle[idx] * amplitude
      srcY = y + sinAngle[idx] * amplitude
    }

    // 用位移后的坐标采样像素...
  }
}

来拆解一下这个波纹的原理:

(1)波纹范围

波纹只出现在扩散前沿后方 180px 的范围内(WAVE_ZONE = 3 × 60),也就是刚刚被扩散到的区域。太远的区域已经稳定了,不需要波纹。

(2)正弦波 × 指数衰减

Math.sin(wavePhase) 产生周期性的正弦波,Math.exp(...) 让波纹随距离衰减。两者结合就是一个逐渐消失的水波纹效果。

(3)方向性位移

还记得前面算距离场时顺便存的 cosAnglesinAngle 吗?这里用它们来确定每个像素的位移方向——沿着从像素到扩散点的方向做径向波纹,而不是简单的横向或纵向位移,这样视觉上更像真实的水波 🌊

(4)淡入淡出

animFade = Math.sin(spreadProgress × π) 是一个在 0→1→0 之间变化的系数。扩散刚开始(progress≈0)和快结束(progress≈1)时波纹很弱,中间最强。避免了波纹突然出现或消失的违和感。

渲染完像素后还有一层高光环叠加:

// 在 putImageData 之后,用 Canvas API 画高光环
if (rippleActive && image.points.length > 0 && currentRadius > 4) {
  ctx.save()
  ctx.globalAlpha = 0.25 * animFade
  for (const pt of image.points) {
    const cx = pt.x * width
    const cy = pt.y * height
    for (let w = 0; w < WAVE_COUNT; w++) {
      const ringRadius = currentRadius - w * WAVE_WIDTH
      if (ringRadius > 0) {
        ctx.beginPath()
        ctx.arc(cx, cy, ringRadius, 0, Math.PI * 2)
        ctx.strokeStyle = `rgba(150, 200, 255, ${0.3 - w * 0.08})`
        ctx.lineWidth = 1.5
        ctx.stroke()
      }
    }
  }
  ctx.restore()
}

以扩散点为圆心,画 3 个淡蓝色的同心圆环,半径就是当前的扩散半径。这些环跟着扩散前沿一起向外推进,配合底层的像素位移,就形成了非常好看的涟漪效果

6、弹跳小球

为了让多张图片之间的切换更有趣,我加了一个可选的弹跳小球动画——一个发光的小球停在当前扩散点上,在图片切换时弹跳到下一张图片的扩散点。

小球的运动轨迹是一个经典的抛物线弹跳

const BOUNCE_DURATION = 500   // 弹跳动画 500ms
const BALL_RADIUS = 12        // 小球半径 12px
const BOUNCE_HEIGHT_RATIO = 0.40  // 弹跳高度 = 两点距离 × 0.4

if (ball.isBouncing) {
  const t = Math.max(0, Math.min(1, (elapsedMs - ball.bounceStartMs) / ball.bounceDuration))

  // 线性插值位置
  let bx = ball.fromX + (ball.toX - ball.fromX) * t
  let by = ball.fromY + (ball.toY - ball.fromY) * t

  // 叠加抛物线弧线:-4t(1-t) 是经典的抛物线公式
  const dist = Math.sqrt((ball.toX - ball.fromX) ** 2 + (ball.toY - ball.fromY) ** 2)
  const bounceH = Math.max(0.10, dist * BOUNCE_HEIGHT_RATIO)
  by += -bounceH * 4 * t * (1 - t)  // 抛物线!

  // 挤压拉伸(squash & stretch)
  if (t < 0.15) {
    // 起跳:横向压扁
    scaleX = 1 + 0.2 * (1 - t / 0.15)
    scaleY = 1 - 0.2 * (1 - t / 0.15)
  } else if (t > 0.85) {
    // 落地:横向压扁
    scaleX = 1 + 0.2 * ((t - 0.85) / 0.15)
    scaleY = 1 - 0.2 * ((t - 0.85) / 0.15)
  } else {
    // 空中:纵向拉长
    const speed = Math.abs(2 * t - 1)
    scaleX = 1 - 0.15 * (1 - speed)
    scaleY = 1 + 0.15 * (1 - speed)
  }
}

这里有几个动画细节值得说一下:

(1)抛物线轨迹

-4t(1-t) 是一个经典的抛物线公式,在 t=0 和 t=1 时值为 0(在地面),在 t=0.5 时达到最大值 -1(在空中最高点)。乘以弹跳高度就得到了一个自然的弹跳弧线

(2)Squash & Stretch(挤压拉伸)

这是动画十二原则中最重要的原则之一!起跳和落地的瞬间小球会被"压扁"(scaleX 变大、scaleY 变小),空中飞行时会被"拉长"。就这一个细节,让小球瞬间从"机械移动"变成了"有弹性的活物"

(3)落地波纹

小球落地时还会触发一个扩散波纹,进一步增强弹跳的"重量感":

// 小球落地时
ball.landingRippleStart = elapsedMs

// 每帧检查波纹状态
const rt = (elapsedMs - ball.landingRippleStart) / LANDING_RIPPLE_DURATION
if (rt >= 0 && rt < 1) {
  const rippleR = BALL_RADIUS + (LANDING_RIPPLE_MAX_R - BALL_RADIUS) * rt
  const rippleAlpha = 0.5 * (1 - rt)
  ctx.beginPath()
  ctx.arc(ball.landingX, ball.landingY, rippleR, 0, Math.PI * 2)
  ctx.strokeStyle = `rgba(120, 180, 255, ${rippleAlpha})`
  ctx.lineWidth = 2 * (1 - rt)
  ctx.stroke()
}

(4)小球本体渲染

小球用径向渐变 + 高光点来模拟玻璃球质感:

// 发光效果
ctx.shadowColor = 'rgba(100, 160, 255, 0.6)'
ctx.shadowBlur = 16

// 径向渐变:白 → 浅蓝 → 蓝
const grad = ctx.createRadialGradient(0, -2, 0, 0, 0, BALL_RADIUS)
grad.addColorStop(0, 'rgba(255, 255, 255, 0.95)')
grad.addColorStop(0.4, 'rgba(180, 210, 255, 0.85)')
grad.addColorStop(1, 'rgba(100, 160, 255, 0.6)')
ctx.arc(0, 0, BALL_RADIUS, 0, Math.PI * 2)
ctx.fillStyle = grad
ctx.fill()

// 高光点
ctx.arc(-3, -4, 3, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'
ctx.fill()

不弹跳时小球还有一个微弱的呼吸浮动Math.sin(elapsedMs / 600) * 0.003),让它看起来是"活"的,而不是死死钉在那里。

7、音乐踩点模式

单纯按固定时间扩散已经很好看了,但配上音乐节拍更带感!Flika 的扩散着色支持踩点模式——每到一个节拍就切换到下一张图片开始扩散。

节拍检测用的是基于 RMS 能量的峰值检测算法:

// 解码音频 → 计算能量 → 平滑 → 峰值检测
const audioContext = new AudioContext()
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
const channelData = audioBuffer.getChannelData(0)

// 10ms 一块计算 RMS 能量
const blockSize = Math.floor(sampleRate * 0.01)
for (let i = 0; i < channelData.length; i += blockSize) {
  let sum = 0
  for (let j = i; j < Math.min(i + blockSize, channelData.length); j++) {
    sum += channelData[j] * channelData[j]
  }
  energyBlocks.push(Math.sqrt(sum / blockSize))
}

// 峰值检测:能量超过局部平均 × 阈值 → 标记为节拍
const threshold = 1.2 + (1 - sensitivity) * 1.5

检测到节拍后,踩点模式下的图片切换逻辑是这样的:

export function resolveBeatSync(
  images: PrecomputedImageData[],
  beats: BeatPure[],
  effectiveTimeMs: number,
): ResolvedSlot {
  const effectiveTimeSec = effectiveTimeMs / 1000

  // 在已有节拍段内查找当前是第几拍
  for (let i = 0; i < beats.length - 1; i++) {
    const segStart = beats[i].time
    const segEnd = beats[i + 1].time
    if (effectiveTimeSec < segEnd) {
      return {
        imageIndex: i % images.length,                 // 第几张图
        imageTime: (effectiveTimeSec - segStart) * 1000, // 本张图已播放时间
        effectiveSpreadDuration: (segEnd - segStart) * 1000, // 本拍间隔 = 扩散时长
      }
    }
  }

  // 节拍不够用?用平均间隔外推!
  const intervalSec = avgBeatInterval(beats)
  const lastBeatSec = beats[beats.length - 1].time
  const timePast = effectiveTimeSec - lastBeatSec
  const extraIdx = Math.floor(timePast / intervalSec)
  return {
    imageIndex: (beats.length - 1 + extraIdx) % images.length,
    imageTime: (effectiveTimeSec - lastBeatSec - extraIdx * intervalSec) * 1000,
    effectiveSpreadDuration: intervalSec * 1000,
  }
}

巧妙的地方在于:每个节拍段的时长就是扩散时长。也就是说,如果两个节拍间隔 800ms,那这张图的扩散就要在 800ms 内完成。节拍快的时候扩散就快,节拍慢的时候扩散就慢,完美跟上音乐的节奏感

还有一个细节——如果图片数量比节拍数多,节拍不够用怎么办?用平均间隔外推。取所有节拍间隔的平均值,在最后一个节拍之后按这个间隔继续切换,保证每张图都能被展示到。

8、Web Worker 导出

渲染一帧扩散效果需要遍历 92 万+ 个像素(1280×720),如果用 30fps 导出一个 10 秒的视频,那就是 300 帧 × 92 万像素 = 2.76 亿次像素计算。放在主线程的话,导出期间整个页面都会卡死,解决方案是把导出渲染丢到 Web Worker 里:

// 主线程
const handle = createWorkerExport({
  canvas,
  images: workerImages,     // 预计算好的图片数据
  config: { ...config },
  beats: [...beats],
  totalDurationMs: totalDuration,
  fps: 30,
  step: 1,                  // 1 = 全质量
  onProgress,               // 进度回调
})
const blob = await handle.start()

// Worker 线程
self.onmessage = (e) => {
  const result = renderFramePure(ctx, elapsedMs, step)
  // Transferable 零拷贝传输
  self.postMessage(
    { buffer: result.buffer },
    [result.buffer.buffer]   // 转移所有权,避免拷贝
  )
}

关键优化点:

优化说明
Web Worker渲染在后台线程,UI 完全不卡
预计算距离场、灰度数据提前算好传给 Worker
Transferable零拷贝传输像素数据,省掉序列化开销
有音频时改用主线程渲染(音频时间驱动,保证踩点同步)

注意最后一点——有音频的踩点模式下,Flika 选择在主线程渲染。因为 MediaRecorder 需要同时录制 Canvas 视频流和 Audio 音频流,音频播放时间就是渲染时钟,必须在主线程同步进行。

9、扩散点编辑

用户可以在画布上点击任意位置添加扩散起点,这里用了归一化坐标来做适配:

function handleCanvasClick(e: MouseEvent) {
  const rect = canvas.getBoundingClientRect()
  const scaleX = canvas.width / rect.width
  const scaleY = canvas.height / rect.height

  // 鼠标位置 → Canvas 像素坐标
  const pixelX = (e.clientX - rect.left) * scaleX
  const pixelY = (e.clientY - rect.top) * scaleY

  // Canvas 像素坐标 → 归一化坐标 (0~1)
  const nx = pixelX / canvas.width
  const ny = pixelY / canvas.height

  img.points.push({ id: uuidv4(), x: nx, y: ny })

  // 重新预计算距离场
  engine.precomputeImage(img)
  // 立即刷新预览
  engine.renderStaticFrame(selectedImageIndex)
}

用归一化坐标(0~1)而不是像素坐标的好处是——不管画布切换横屏还是竖屏、放大还是缩小,扩散点的相对位置始终不变

还有一个便捷的功能——一键添加。对于懒得手动点的用户,一键给所有没有扩散点的图片随机添加一个(坐标在 15%~85% 之间,避免太靠边缘):

function autoAddPoints() {
  for (const img of images) {
    if (img.points.length === 0) {
      const x = 0.15 + Math.random() * 0.7
      const y = 0.15 + Math.random() * 0.7
      img.points.push({ id: uuidv4(), x, y })
    }
  }
}

总结

最后梳理一下扩散着色效果涉及到的核心技术点:

步骤技术作用
1️⃣灰度转换(BT.601)生成"未上色"的底图
2️⃣距离场预计算确定每个像素的扩散顺序
3️⃣smoothstep 混合让边缘过渡柔和自然
4️⃣正弦波 × 指数衰减涟漪光波像素位移
5️⃣抛物线 + squash/stretch弹跳小球物理动画
6️⃣RMS 能量峰值检测自动识别音乐节拍
7️⃣节拍段映射让扩散时长跟上节奏
8️⃣Web Worker + Transferable导出不卡 UI

全程纯 Canvas 2D + 原生像素操作,没用任何图形库和 shader,代码量约 1200 行(引擎) + 500 行(渲染工具函数),完全用前端技术栈就能搞定

Flika 还有踩点转场、胶片放映、墨水渲染、碎片拼贴、粒子重组等很多动画模式,后面有机会再一一拆解~

源码地址

Gitee

gitee.com/zheng_yongt…

Github

github.com/yongtaozhen…

🌟 觉得有帮助的可以点个 star~

🖊 有什么问题或错误可以指出,欢迎 pr~

📬 有什么想要实现的功能或想法可以联系我~

公众号

关注公众号『 前端也能这么有趣 』,获取更多有趣内容。

发送 加群 还可以加入群聊,一起来学习(摸鱼)吧~

说在后面

🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『前端也能这么有趣』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。