如何实现一个网页版的剪映(二)

26 阅读14分钟

前言

本文章将由毛泽东选集中的《实践论》的思想来指导我们学习

《实践论》指出:认识的过程,第一步,是开始接触外界事情,属于感觉的阶段。第二步,是综合感觉的材料加以整理和改造,属于概念、判断和推理的阶段。只有感觉的材料十分丰富(不是零碎不全)和合于实际(不是错觉),才能根据这样的材料造出正确的概念和论理来

本文的写作目标,是实现一个简易版“剪映”。要达到这个目标,首先必须对音视频的基本原理有充分的接触与了解。

比如:

  • 音视频的基本结构是什么?
  • 编码与解码是如何工作的?

这些内容,在上一篇文章中已经进行了基础介绍。但必须承认,仅凭一篇文章的材料,是远远不够的。真正的“感觉材料丰富”,意味着:

  • 查看官方文档
  • 阅读开源项目源码
  • 动手写最小 Demo
  • 对比不同技术方案

感性认识阶段,本质上就是“多看、多试、多踩坑”。这一阶段的核心,不是追求系统性,而是追求广度与真实接触。

第二步,当材料积累到一定程度后,就不能再停留在零散知识点层面,而必须进入第二阶段:整理、归纳、抽象(必须经过思考作用,将丰富的感觉材料加以去粗取精、去伪存真、由此及彼、由表及里的改造制作工夫,造成概念和理论的系统,就必须从感性认识跃进到理性认识)

对于“简易剪映”这个目标来说,第二步具体体现为:把零散知识整理成结构:

  • 输入层:文件读取、解封装(MP4?)
  • 解码层:音视频解码(WebCodes)
  • 数据层:帧数据 / 音频数据处理
  • 时间轴(canvas/dom实现,如果是canvas要使用什么库,pixi/konva/fabric)
  • 渲染层
  • 导出层(重新编码)

本篇文章是对上一篇文章的补充,更加深入地了解WebCodes,以及介绍一些实现细节

回顾

首先回顾一下处理音视频的流程:

  1. 一个MP4文件,本质是 “h264 视频流 + aac 音频流 + 元信息” 的封装体,我们想要编辑音视频,首先要做的就是把这个封装壳给去掉,这一步叫Demuxing(解封装/解复用)
  2. 解复用后的流是编码后的裸流(如 H.264 裸流、AAC 裸流),还需要经过解码(Decoding)  才能得到原始的像素数据(YUV)和音频采样数据(PCM)

通过第一步(解复用)和第二步(解码)后获得的数据,我们才能在屏幕上绘制出来

WebCodes相关概念

解复用

当视频播放器读取视频文件进行播放时,它需要的信息不仅仅是编码的视频帧和编码的音频,它还需要有关视频的元数据,例如音轨、视频时长、帧速率、分辨率等。

每种视频文件格式(例如 MP4 和 WebM)都有自己的规范,规定了如何在文件中存储元数据和音频/视频数据,以及如何提取这些信息。

将数据存储到文件中称为复用,从文件中提取数据称为解复用。

解复用库除了上一篇文章提到的MP4box.js,还有mediabunnyweb-demuxer

mediabunny

Mediabunny 是一个用纯 TypeScript 写的媒体工具箱,可以在浏览器里高效操作视频和音频文件,类似于 Web 版的 FFmpeg

Mediabunny 可以:

  • 读取文件结构、元数据、轨道信息
  • 解出压缩数据或 PCM 样本
  • 写出新的文件并生成最终可下载的媒体文件

支持的容器格式包括:

  • MP4 / MOV / MKV / WebM / MP3 / WAV / OGG / AAC / FLAC / TS 等
import { EncodedPacketSink, Input, ALL_FORMATS, BlobSource } from 'mediabunny';

const input = new Input({
  formats: ALL_FORMATS,
  source: new BlobSource(file),
});

const videoTrack = await input.getPrimaryVideoTrack();
const sink = new EncodedPacketSink(videoTrack);

for await (const packet of sink.packets()) {
  const chunk = packet.toEncodedVideoChunk();
}

web-demuxer

web-demuxer 是用来“拆容器”(解封装)的,不负责解码,只负责把音视频轨道拆出来,类似于MP4box.js

import { WebDemuxer } from "web-demuxer";

const demuxer = new WebDemuxer();

// Example: Get video frame at specific time
async function seek(file, time) {
  // 1. Load video file
  await demuxer.load(file);

  // 2. Demux video file and generate VideoDecoderConfig and EncodedVideoChunk required by WebCodecs
  const videoDecoderConfig = await demuxer.getDecoderConfig('video');
  const videoEncodedChunk = await demuxer.seek('video', time);

  // 3. Decode video frame through WebCodecs
  const decoder = new VideoDecoder({
    output: (frame) => {
      // Render frame, e.g., using canvas drawImage
      frame.close();
    },
    error: (e) => {
      console.error('video decoder error:', e);
    }
  });

  decoder.configure(videoDecoderConfig);
  decoder.decode(videoEncodedChunk);
  decoder.flush();
}

解码视频

EncodedVideoChunk

EncodedVideoChunk中文名顾名思义——已编码的视频块,表示编码过的VideoFrame

目前,WebCodecs只能对原始视频数据——编码过的视频数据这2者进行相互转换。为什么WebCodes不支持解复用? 因为解复用很容易通过第三方库来实现。库无法做到在没有浏览器辅助的情况下访问硬件加速的视频编码或解码,而硬件加速正是 WebCodecs 的作用所在。

IBP帧的相关介绍已在上一篇文件进行讲解,这里不再赘述

在上一篇文章中,我们介绍了通过mp4box.js获取sample,再把sample放入EncodedVideoChunk的data当中

videoSamples.forEach((s) => { 
    videoDecoder.decode( // 将sample转换成EncodedVideoChunk,可以被VideoDecoder进行解码 
        new EncodedVideoChunk({ 
            type: s.is_sync ? "key" : "delta", 
            timestamp: (1e6 * s.cts) / s.timescale, 
            duration: (1e6 * s.duration) / s.timescale, 
            data: s.data, 
        }) 
    ); 
});

在这篇文章,会介绍通过Mediabunny获取EncodedVideoChunk的方式,下面代码的chunk就是EncodedVideoChunk对象

import { EncodedPacketSink, Input, ALL_FORMATS, BlobSource } from 'mediabunny';

const input = new Input({
  formats: ALL_FORMATS,
  source: new BlobSource(file),
});

const videoTrack = await input.getPrimaryVideoTrack();
const sink = new EncodedPacketSink(videoTrack);

for await (const packet of sink.packets()) {
  const chunk:EncodedVideoChunk = packet.toEncodedVideoChunk();
}

VideoFrame

VideoFrame类表示为像素数据(如 RGB 值)和一些元数据(包括时间戳)的组合

由于有完整的像素信息,你可以把它通过canvas画出来或者交给AI进行处理

VideoFrame可以从图像源(<canvas><video>ImageBitmap)创建,通过这种方式不需要指定formatcodedHeightcodedWidth,因为这些图像源已经带有宽高信息

通过原始二进制数据(ArrayBufferUInt8Array)构建VideoFrame,这就需要指定formatcodedHeightcodedWidth。需要注意的是,由于VideoFrame是存在内存中的,通过二进制传输数据会有内存复制操作,从而产生性能开销

// 通过canvas创建
const cnv = document.createElement("canvas");
const frameFromCanvas = new VideoFrame(cnv, { timestamp: 0 });

// 通过二进制创建
const pixelSize = 4;
const init = {
  timestamp: 0,
  codedWidth: 320,
  codedHeight: 200,
  format: "RGBA",
};
const data = new Uint8Array(init.codedWidth * init.codedHeight * pixelSize);
for (let x = 0; x < init.codedWidth; x++) {
  for (let y = 0; y < init.codedHeight; y++) {
    const offset = (y * init.codedWidth + x) * pixelSize;
    data[offset] = 0x7f; // Red
    data[offset + 1] = 0xff; // Green
    data[offset + 2] = 0xd4; // Blue
    data[offset + 3] = 0x0ff; // Alpha
  }
}
init.transfer = [data.buffer];
const frame = new VideoFrame(data, init);

// 通过video创建
const video = document.querySelector("video");
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
const updateCanvas = (now, metadata) => {
    ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
    video.requestVideoFrameCallback(updateCanvas);
};
video.requestVideoFrameCallback(updateCanvas);

问题1:codedHeightdisplayHeight的区别

注意到VideoFrame对象具有codedWidthdisplayWidth属性。你可能会好奇为什么会有两个宽度属性,但这与视频压缩算法的工作原理有关

一段 1080p 视频可能使用全部 16x16 的宏块,但你会发现 1080 像素无法被 16 整除1080/16=67.5。一种解决方法是向上取整,编码 1088 像素,但告诉视频播放器只显示 1080 像素,丢弃一部分数据

codedHeight这本质上就是和之间的区别displayHeight。实际上,对于大多数视频来说,它们是相同的,但并非总是如此,而且对于要渲染的图像和画布的大小,使用displayWidth和更安全

问题2:为什么需要decoder.flush()

如上一篇文章的实例代码,我们会看到decoder.flush(),那么这个函数有什么用呢?

解码并非简单的异步过程,并不能直接await decoder.decode(chunk)

因为解码不仅仅是计算量很大的功能,有时视频包含 B 帧(参考第一篇文章),这些帧需要以与显示顺序不同的顺序进行解码

因此,解码器必须维护一个内部缓冲区才能正常工作。如果我们一直decoder.decode(),由于B帧的存在(这些帧中的视频播放顺序与实际播放顺序不同),WebCodes并不知道这是不是最后一帧,所以需要调用decoder.flush()来将缓冲区里面的帧取出

如果不调用这个函数,那么最后几帧可能永远不会生成

同时,由于内部缓冲区的存在,在第一个渲染帧出现之前,可能需要发送 3 到 5 个数据块进行解码

注意:当调用了decoder.flush(),发送进行处理的下一个数据块必须是关键帧chunk.type === 'key'),否则解码器将抛出错误。

解码音频

遗憾的是,WebCodecs 仅支AAC MP4 文件和Opus WebM 文件的音频,它无法处理 MP3 或其他音频格式。如果要导出MP3,只能寻求第三方库的帮助了。Mediabunny 也可以通过扩展,例如用 WASM 的方式补充这类编码能力(如 @mediabunny/mp3-encoder)。

音频的编码和解码比视频的编码和解码容易得多。它运行在 CPU 上,不需要硬件加速。而且,它也没有像B帧那样有依赖关系

EncodedAudioChunk

要从视频文件中读取EncodedAudioChunk,API与视频解码的API非常相似。

import { EncodedPacketSink, Input, ALL_FORMATS, BlobSource } from 'mediabunny';

const input = new Input({
  formats: ALL_FORMATS,
  source: new BlobSource(file),
});

const audioTrack = await input.getPrimaryAudioTrack();
const sink = new EncodedPacketSink(audioTrack);

for await (const packet of sink.packets()) {
  const chunk = packet.toEncodedAudioChunk();
}

AudioData

在WebCodecs中解码音频时,解码器将返回一个AudioData对象,每个AudioData对象通常代表0.2 - 0.5 秒的音频。

VideoFrame一样,AudioData也会占用大量内存。因此,在处理完时,也需要调用close()

每秒音频文件的大小约为44100 采样率/s × 2 声道 × 4 bytes = 352800 bytes ~ 344 KB,这意味着播放一小时的音频大约需要 1.27GB 的内存

如果要读取AudioDataFloat32Arrays数据,需要为每个通道创建一个Float32Array,然后调用copyTo方法。

f32-planarf32这2种格式上篇文章有讲

如果是格式是f32-planar

const decodedAudio:AudioData[] = decodeAudio(encoded_audio);
for(const audioData of decodedAudio){
    const left = new Float32Array(audioData.numberOfFrames);
    const right = new Float32Array(audioData.numberOfFrames);
    audioData.copyTo(left, {frameOffset: 0, planeIndex: 0});
    audioData.copyTo(right, {frameOffset: 0, planeIndex: 1});
}

如果是格式是f32

const decodedAudio:AudioData[] = decodeAudio(encoded_audio);

for(const audioData of decodedAudio){
    const data = new Float32Array(audioData.numberOfFrames * audioData.numberOfChannels);
    audioData.copyTo(data, {frameOffset: 0});

    // [L, R, L, R, L, R, ...]
    const left = new Float32Array(audioData.numberOfFrames);
    const right = new Float32Array(audioData.numberOfFrames);

    for(let i = 0; i < audioData.numberOfFrames; i++){
        left[i] = data[i * 2];
        right[i] = data[i * 2 + 1];
    }
}

通过上面的代码,我们就能获取音频的二进制数据,我们就能对音频进行任意操作了。例如:音频调整声音大小、混合音频、重采样

WebAudio

视频可以通过<video> <canvas>标签渲染,而音频除了有<audio>还可以通过WebAudio进行播放声音

在 WebAudio 中,你需要将音频处理视为一个管道,其中包含源、目标和节点(中间效果/滤波器),例如

const ctx = new AudioContext();

// AudioBuffer是WebAudio对原始音频数据的表示
const rawFileBinary:ArrayBuffer = await file.arrayBuffer();
const audioBuffer:AudioBuffer = await ctx.decodeAudioData(rawFileBinary);

const sourceNode:AudioNode = ctx.createBufferSource();
const gainNode:AudioNode  = ctx.createGain();

sourceNode.connect(gainNode);
gainNode.connect(ctx.destination);

sourceNode.start();

image.png

你可以像这样获取原始音频样本:

const leftChannel:Float32Array = audioBuffer.getChannelData(0);
const rightChannel:Float32Array = audioBuffer.getChannelData(1);

问题3:处理音频时,什么时候用WebCodes,什么时候用WebAudio

先说WebAudio,WebAudio是“黑盒解码”,它的定位是“我要把这段音频解开,然后播放或处理。”你不能:控制解码帧、控制解码节奏、控制缓存大小、逐帧处理,他的模式如下:

文件 → 一次性解码 → AudioBuffer

WebCodecs是“底层帧级解码”,他可以控制什么时候 decode、一块一块喂数据、精确时间戳、自己管理缓冲,他的模式如下:

chunk → PCM
chunk → PCM
chunk → PCM

如果想实现一个视频播放器,最好使用 WebAudio

如果想实现音频编辑或音频转码(处理音频文件并将其导出为MP3),得使用第三方库来处理

如果想实现类似剪映这样多媒体编辑,支持导出为视频和纯音频文件,WebAudio用于实时音频播放、用于视频导出的 WebCodecs、用于纯音频导出的第三方库

问题4:PCM、AudioData和AudioBuffer区别

压缩音频(MP3/AAC)  
    ↓ 解码  
PCM(纯数据)  
    ↓ 被包装  
AudioData(WebCodecs)  
    ↓ 再包装  
AudioBuffer(Web Audio)  
    ↓  
播放

PCM(Pulse Code Modulation)本质是:原始音频采样数值,比如:[0.32,-0.13,0.57],这些数字代表每一个采样点的振幅。

当你用AudioDecoder解码时得到的就是AudioData,它本质是:一块带时间信息的 PCM 数据帧。它包含:PCM 数据、timestamp、duration、sampleRate、numberOfChannels。所以,AudioData = PCM + 时间戳 + 格式元信息,它的定位是:音频帧

AudioBuffer是为 AudioContext 准备的可播放音频资源,它内部其实也是 PCM,但已经整理成 Web Audio 需要的结构:

AudioBuffer
 ├─ channel 0Float32Array
 ├─ channel 1Float32Array
 ├─ sampleRate
 └─ length

所以,AudioBuffer = 为播放系统准备好的PCM容器,可以直接丢进AudioBufferSourceNode进行播放

部分实现细节

时间刻度尺的实现

动画1.gif

核心思路:

import { useEffect, useRef } from "react";

interface TimelineRulerProps {
  zoomLevel: number;
}
function TimelineRuler({ zoomLevel, width = 800 }: TimelineRulerProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    const dpr = window.devicePixelRatio || 1;
    canvas.style.width = `${width}px`;
    canvas.style.height = `24px`;
    canvas.width = Math.floor(width * dpr);
    canvas.height = Math.floor(24 * dpr);
    ctx.scale(dpr, dpr);
    ctx.clearRect(0, 0, width, 24);
    
    // 每秒钟占多少像素
    const pixelsPerSecond = 20 * zoomLevel;

    ctx.fillStyle = "#fff";
    ctx.strokeStyle = "#fff";
    ctx.lineWidth = 1;
    ctx.textAlign = "center";
    ctx.textBaseline = "top";

    // 最小文字间距
    const minTextSpacing = 50;
    const frameInterval = 1 / 30;
    const intervalOptions = [frameInterval,0.1,0.5,1,2,5,10,15,30,60,120,300];
    let mainInterval = 300;

    // opt * pixelsPerSecond 代表主刻度的px,寻找第一个大于最小间距的主刻度
    for (const opt of intervalOptions) {
      if (opt * pixelsPerSecond >= minTextSpacing) {
        mainInterval = opt;
        break;
      }
    }

    const formatTime = (seconds: number) => {
      if (mainInterval === frameInterval) {
        // 显示帧数,四舍五入到最近帧
        const frameNumber = Math.round(seconds * 30);
        return `${frameNumber}f`;
      }

      if (mainInterval < 1) {
        return seconds.toFixed(1) + "s";
      }

      const m = Math.floor(seconds / 60);
      const s = Math.floor(seconds % 60);

      if (m > 0 && s === 0) {
        return `${m}m`;
      }
      if (m === 0 && s === 0) {
        return "0s";
      }
      return `${m}:${s.toString().padStart(2, "0")}`;
    };

    // 根据主刻度间隔确定子刻度数量
    let subTickCount = 5;
    if (mainInterval === frameInterval) subTickCount = 1;
    if (mainInterval === 0.1) subTickCount = 2; // 0.05
    if (mainInterval === 1) subTickCount = 5; // 0.2
    if (mainInterval === 60) subTickCount = 4; // 15s
    // 子刻度间隔
    const subInterval = mainInterval / subTickCount;
    // 宽度能放下多少秒
    const rangeEnd = width / pixelsPerSecond;
    // 计算需要绘制多少个子刻度
    const count = Math.ceil(rangeEnd / subInterval) + 1;

    for (let i = 0; i < count; i++) {
      const time = i * subInterval;
      // 将刻度线对齐到像素中心,避免模糊
      const x = Math.floor(time * pixelsPerSecond) + 0.5;
      if (x > width) break;
      ctx.beginPath();

      // 判断是否为主刻度
      const isMain =
        Math.abs(time % mainInterval) < 0.001 ||
        Math.abs((time % mainInterval) - mainInterval) < 0.001;

      if (isMain) {
        ctx.moveTo(x, 18);
        ctx.lineTo(x, 24);
        const text = formatTime(time);
        ctx.fillText(text, x, 4);
      } else {
        ctx.moveTo(x, 21);
        ctx.lineTo(x, 24);
      }
      ctx.stroke();
    }
  }, [zoomLevel, width]);

  return <canvas ref={canvasRef} style={{ height: "24px" }} />;
}

export default TimelineRuler;

音频波形图的实现

本节将实现一个音频波形图的最小案例

通过konva绘制音波、滚动条(如图所示,里面的柱子以及滚动条全是canvas画出来的) 动画.gif

import Konva from 'konva';

import { EventEmitter } from './EventEmitter';

interface TimeRange {
  id: number;
  startTime: string;
  endTime: string;
}

interface TimelineCanvasEvents {
  timeRangeClick: TimeRange;
  scroll: number;
}

class TimeLine extends EventEmitter<TimelineCanvasEvents> {
  private audioBuffer: AudioBuffer | null = null;
  private canvasHeight: number;
  private canvasWidth: number;
  private duration = 0;
  private layer: Konva.Layer = new Konva.Layer();
  private maxCanvasWidth = 0;
  private scrollBarLayer: Konva.Layer = new Konva.Layer();
  private stage: Konva.Stage | null = null;
  private timeRanges: TimeRange[] = [];
  private waveformData: { left: number[]; right: number[] } = {
    left: [],
    right: [],
  };
  private zoomLevel = 1;

  constructor(el: HTMLElement) {
    super();
    const { clientWidth, clientHeight } = el;
    this.canvasWidth = clientWidth;
    this.canvasHeight = clientHeight;
    this.stage = new Konva.Stage({
      container: 'wave',
      width: this.canvasWidth,
      height: this.canvasHeight,
    });
    this.stage.add(this.scrollBarLayer);
    this.stage.add(this.layer);
    this.drawScrollBar();
  }
  dispose() {
    this.stage?.destroy();
  }
  draw() {
    if (!this.audioBuffer) return;
    this.drawWaveform();
  }
  setAudioBuffer(audioBuffer: AudioBuffer) {
    this.audioBuffer = audioBuffer;
    this.duration = audioBuffer.duration;
    this.updateWaveformData();
    this.updateMaxCanvasWidth();
    this.scrollBarLayer.x(0);
    this.updateScrollBarWidth();
  }
  setTimeRanges(timeRanges: TimeRange[]) {
    this.timeRanges = timeRanges;
  }
  setZoomLevel(zoomLevel: number) {
    this.zoomLevel = zoomLevel;
    this.updateMaxCanvasWidth();
    this.updateWaveformData();
    this.updateScrollBarWidth();
    this.draw();
  }
  updateMaxCanvasWidth() {
    this.maxCanvasWidth = this.duration * this.zoomLevel * 20;
  }
  updateSize(width: number, height: number) {
    this.canvasWidth = width;
    this.canvasHeight = height;
    if (this.stage) {
      this.stage.width(width);
      this.stage.height(height);
      this.stage.draw();
    }
  }
  updateWaveformData() {
    if (!this.audioBuffer) return;
    this.waveformData = this.extractWaveformData(
      this.audioBuffer,
      this.duration * this.zoomLevel * 8,
    );
  }

  private drawScrollBar() {
    const stage = this.stage;
    if (!stage) return;
    const PADDING = 5;
    const horizontalBar = new Konva.Rect({
      width: 0,
      height: 10,
      fill: 'grey',
      opacity: 0.8,
      x: PADDING,
      y: stage.height() - PADDING - 10,
      draggable: true,
      dragBoundFunc(pos) {
        pos.x = Math.max(
          Math.min(pos.x, stage.width() - this.width() - PADDING),
          PADDING,
        );
        pos.y = stage.height() - PADDING - 10;
        return pos;
      },
    });
    this.scrollBarLayer.add(horizontalBar);
    horizontalBar.on('dragmove', () => {
      // 百分比的增量
      const availableWidth =
        stage.width() - PADDING * 2 - horizontalBar.width();
      const delta = (horizontalBar.x() - PADDING) / availableWidth;
      const newX = -(this.maxCanvasWidth - stage.width()) * delta;
      this.layer.x(newX);
      this.emit('scroll', Math.abs(newX));
    });
  }

  // 绘制波形图
  private drawWaveform() {
    if (!this.audioBuffer) return;
    this.layer.destroyChildren();
    this.layer.draw();

    const { left, right } = this.waveformData;
    const barWidth = this.maxCanvasWidth / Math.max(left.length, right.length);

    // 计算最大振幅,确保不超过 canvas 高度
    const maxLeftAmplitude = Math.max(...left, 0.01);
    const maxRightAmplitude = Math.max(...right, 0.01);
    const maxAmplitude = Math.max(maxLeftAmplitude, maxRightAmplitude);

    // 计算缩放因子,确保波形不超过 canvas 高度
    const scaleFactor = this.canvasHeight / 2 / maxAmplitude;

    // 绘制左声道(中心线上方)
    left.forEach((amplitude, index) => {
      const barHeight = amplitude * scaleFactor;

      const bar = new Konva.Rect({
        x: index * barWidth,
        y: this.canvasHeight / 2 - barHeight,
        width: Math.max(barWidth - 1, 1),
        height: barHeight,
        fill: '#1890ff',
        draggable: false,
      });
      this.layer.add(bar);
    });
    // 绘制右声道(中心线下方)
    right.forEach((amplitude, index) => {
      const barHeight = amplitude * scaleFactor;

      const bar = new Konva.Rect({
        x: index * barWidth,
        y: this.canvasHeight / 2,
        width: Math.max(barWidth - 1, 1),
        height: barHeight,
        fill: '#ff4d4f',
        draggable: false,
      });

      this.layer.add(bar);
    });

    // 添加中心线
    const centerLine = new Konva.Line({
      points: [
        0,
        this.canvasHeight / 2,
        this.canvasWidth,
        this.canvasHeight / 2,
      ],
      stroke: '#d9d9d9',
      strokeWidth: 1,
      draggable: false,
    });
    this.layer.add(centerLine);

    this.layer.draw();
  }

  // 提取波形数据
  private extractWaveformData(audioBuffer: AudioBuffer, samples: number) {
    // 提取左声道数据
    const leftChannelData = audioBuffer.getChannelData(0);
    // 提取右声道数据,如果只有单声道,则使用左声道数据
    const rightChannelData =
      audioBuffer.numberOfChannels > 1
        ? audioBuffer.getChannelData(1)
        : leftChannelData;

    const blockSize = Math.floor(
      Math.min(leftChannelData.length, rightChannelData.length) / samples,
    );
    const leftWaveformData: number[] = [];
    const rightWaveformData: number[] = [];

    for (let i = 0; i < samples; i++) {
      const start = i * blockSize;
      const end = start + blockSize;
      let leftSum = 0;
      let rightSum = 0;

      for (let j = start; j < end; j++) {
        leftSum += Math.abs(leftChannelData[j] || 0);
        rightSum += Math.abs(rightChannelData[j] || 0);
      }

      leftWaveformData.push(leftSum / blockSize);
      rightWaveformData.push(rightSum / blockSize);
    }

    return { left: leftWaveformData, right: rightWaveformData };
  }

  private updateScrollBarWidth() {
    const horizontalBar = this.scrollBarLayer.find('Rect')[0]!;
    if (this.maxCanvasWidth < this.canvasWidth) {
      horizontalBar.hide();
    } else {
      horizontalBar.show();
    }
    horizontalBar.width(
      (this.canvasWidth / this.maxCanvasWidth) * this.canvasWidth,
    );
    this.scrollBarLayer.moveToTop();
  }
}

export default TimeLine;

export class EventEmitter<T extends Record<string, any>> {
  private events: Record<string, Array<(data: any) => void>> = {};

  on<K extends keyof T>(eventName: K, callback: (data: T[K]) => void) {
    if (!this.events[eventName as string]) {
      this.events[eventName as string] = [];
    }
    this.events[eventName as string]?.push(callback);
    return this;
  }

  protected emit<K extends keyof T>(eventName: K, data: T[K]) {
    const listeners = this.events[eventName as string];
    if (listeners) {
      listeners.forEach((callback) => callback(data));
    }
  }
}