webcodecs解码视频demo

webcodecs解码视频demo

// useVideoFramePlayer
/* eslint-disable no-unused-vars */
import { ref, readonly, onUnmounted } from 'vue'
import * as MP4Box from 'mp4box'
// 最大缓存帧数
const MAX_CACHE_SIZE = 100
// --- 类型定义 ---
interface MP4BoxBuffer extends ArrayBuffer {
  fileStart: number
}

type VideoFrameInfo = {
  frame: VideoFrame
  index: number
}

type VideoMetaInfo = {
  duration: number
  codec: string
  width: number
  height: number
  fps: number
}

interface MP4BoxObject {
  write: (stream: MP4BoxStream) => void
}

interface MP4BoxStream {
  buffer: ArrayBuffer
}

interface MP4BoxEntry {
  avcC?: MP4BoxObject
  hvcC?: MP4BoxObject
}

interface MP4VideoTrack {
  id: number
  codec: string
  track_width: number
  track_height: number
  nb_samples: number
  duration: number
  timescale: number
  description?: unknown
}

interface MP4Info {
  duration: number
  timescale: number
  isFragmented: boolean
  isProgressive: boolean
  hasMoov: boolean
  fps: number
  videoTracks: MP4VideoTrack[]
}
// --- 辅助函数 ---

function setupMp4box(
  file: File,
  onConfig: (config: VideoDecoderConfig) => void,
  onChunk: (chunk: EncodedVideoChunk) => void,
  onReadyInfo: (info: MP4Info) => void
): Promise<void> {
  const mp4boxfile = MP4Box.createFile()

  return new Promise((resolve, reject) => {
    mp4boxfile.onReady = (info) => {
      const videoTrack = info.videoTracks[0]
      if (!videoTrack) {
        reject(new Error('文件中未找到视频轨道!'))
        return
      }
      // 计算 FPS
      const fps = videoTrack.nb_samples > 0
        ? (videoTrack.nb_samples / ((videoTrack.duration / videoTrack.timescale) || 1))
        : 30

      onReadyInfo({...info, fps })

      // --- 关键:提取 AVCDecoderConfigurationRecord ---
      let description: ArrayBuffer | undefined

      // 获取 Track 对象
      const trak = mp4boxfile.getTrackById(videoTrack.id)

      const entry = trak?.mdia?.minf?.stbl?.stsd?.entries[0] as MP4BoxEntry

      // 获取 avcC (H.264) 或 hvcC (H.265) box
      const codecBox = entry.avcC || entry.hvcC

      if (codecBox) {
        // 创建 DataStream 写入 buffer
        const BIG_ENDIAN = (MP4Box.DataStream as typeof MP4Box.DataStream & { BIG_ENDIAN: number }).BIG_ENDIAN
        const stream = new MP4Box.DataStream(undefined, 0, BIG_ENDIAN)
        codecBox.write(stream)
        // 这里的 buffer 包含了 [Size(4)][Type(4)][Data...]
        // WebCodecs 需要的是 Data 部分,所以切掉前 8 个字节
        description = new Uint8Array(stream.buffer.slice(8)).buffer
      } else {
        console.warn('未找到 avcC/hvcC box,可能导致解码失败')
      }

      const config: VideoDecoderConfig = {
        codec: videoTrack.codec,
        codedWidth: videoTrack.track_width,
        codedHeight: videoTrack.track_height,
        description: description // 传入二进制配置
      }

      onConfig(config)

      mp4boxfile.setExtractionOptions(videoTrack.id)
      mp4boxfile.start()
    }

    mp4boxfile.onSamples = (trackId, user, samples) => {
      for (const sample of samples) {
        if (!sample.data) {
          console.warn(`跳过损坏的帧 (Track: ${trackId})`)
          continue
        }
        // --- 关键:时间戳单位转换 (转换 timescale 到 微秒) ---
        const type = sample.is_sync ? 'key' : 'delta'
        const chunk = new EncodedVideoChunk({
          type: type as 'key' | 'delta',
          timestamp: (1e6 * sample.cts) / sample.timescale, // 微秒
          duration: (1e6 * sample.duration) / sample.timescale, // 微秒
          data: sample.data
        })
        onChunk(chunk)
      }
    }

    mp4boxfile.onError = (error) => reject(error)

    // 读取文件
    const reader = new FileReader()
    reader.onload = (e) => {
      const buffer = e.target?.result as ArrayBuffer
      if (buffer) {
        const mp4boxBuffer = buffer as MP4BoxBuffer
        mp4boxBuffer.fileStart = 0
        mp4boxfile.appendBuffer(mp4boxBuffer)
        mp4boxfile.flush()
        resolve()
      } else {
        reject(new Error('文件读取失败'))
      }
    }
    reader.onerror = (error) => reject(error)
    reader.readAsArrayBuffer(file)
  })
}

// --- 主 Composable 函数 ---
export function useVideoFramePlayer() {
  const frames = ref<VideoFrameInfo[]>([])
  const currentFrameIndex = ref(0)
  const isPlaying = ref(false)
  const videoInfo = ref<VideoMetaInfo | null>(null)
  const isReady = ref(false)
  const isLoading = ref(false)

  let decoder: VideoDecoder | null = null
  let playbackTimer: number | null = null

  // 缓存大小
  const maxCacheSize = MAX_CACHE_SIZE

  const addFrame = (frame: VideoFrame) => {
    // 将帧加入数组
    frames.value.push({ frame, index: frames.value.length })

    if (!isReady.value) {
      isReady.value = true
    }
    applyCachePolicy()
  }

  const applyCachePolicy = () => {
    const SAFETY_BUFFER = 50 // 保留当前帧后面的多少帧
    if (frames.value.length > maxCacheSize) {
      // 只有当头部帧的索引 小于 (当前索引 - 安全距离) 时才清理
      if (frames.value[0].index < currentFrameIndex.value - SAFETY_BUFFER) {
        console.log('执行清理')
        const frameToClose = frames.value.shift()
        if (frameToClose) {
          frameToClose.frame.close()
        }
      }
    }
  }

  const configureDecoder = (config: VideoDecoderConfig) => {
    if (!('VideoDecoder' in window)) {
      alert('您的浏览器不支持 WebCodecs API!')
      return
    }

    // 如果之前有解码器,先清理
    if (decoder) {
      if(decoder.state !== 'closed') decoder.close()
    }

    decoder = new VideoDecoder({
      output: addFrame,
      error: (e) => {
        console.error('解码错误:', e)
        isLoading.value = false
      }
    })
    decoder.configure(config)
  }

  const decodeChunk = (chunk: EncodedVideoChunk) => {
    try {
      decoder?.decode(chunk)
    } catch(e) {
      console.error('Decode error', e)
    }
  }

  const resetState = () => {
    pause()
    if (decoder && decoder.state !== 'closed') decoder.close()
    decoder = null
    // 显式关闭所有帧,防止内存泄漏
    frames.value.forEach(f => f.frame.close())
    frames.value = []

    currentFrameIndex.value = 0
    isReady.value = false
    videoInfo.value = null
  }

  const loadVideo = async (file: File) => {
    if (isLoading.value) return
    isLoading.value = true
    resetState()

    try {
      await setupMp4box(
        file,
        configureDecoder,
        decodeChunk,
        (info: MP4Info) => {
          videoInfo.value = {
            duration: info.duration / info.timescale,
            codec: info.videoTracks[0].codec,
            width: info.videoTracks[0].track_width,
            height: info.videoTracks[0].track_height,
            fps: info.fps || 24
          }
        }
      )
    } catch (error) {
      console.error('处理视频文件时出错:', error)
      alert((error as Error).message)
    } finally {
      isLoading.value = false
    }
  }

  const play = () => {
    if (isPlaying.value || !isReady.value) return
    isPlaying.value = true

    const fps = videoInfo.value?.fps || 24
    const frameInterval = 1000 / fps

    const playbackLoop = () => {
      if (!isPlaying.value) return

      const nextIndex = currentFrameIndex.value + 1

      // 检查下一帧是否已经在 buffer 中
      const nextFrameExists = frames.value.some(f => f.index === nextIndex)

      if (nextFrameExists) {
        currentFrameIndex.value = nextIndex
        playbackTimer = Number(setTimeout(playbackLoop, frameInterval))
      } else {
        // 如果到了末尾,或者缓冲没跟上
        if (frames.value.length > 0 && nextIndex > frames.value[frames.value.length - 1].index) {
          // 真的结束了(假设这里不处理 buffering 等待,简单处理为暂停)
          pause()
        } else {
          // 中间断档(掉帧/乱序的情况)
          playbackTimer = Number(setTimeout(playbackLoop, frameInterval))
        }
      }
    }
    playbackLoop()
  }

  const pause = () => {
    isPlaying.value = false
    if (playbackTimer) {
      clearTimeout(playbackTimer)
      playbackTimer = null
    }
  }

  const seek = (index: number) => {
    pause()
    // 测试用 只能在已解码的帧中跳转
    if (frames.value.some(f => f.index === index)) {
      currentFrameIndex.value = index
    }
  }

  const nextFrame = () => seek(currentFrameIndex.value + 1)
  const prevFrame = () => seek(currentFrameIndex.value - 1)

  onUnmounted(() => {
    resetState()
  })

  return {
    frames: readonly(frames),
    currentFrameIndex,
    isPlaying: readonly(isPlaying),
    isReady: readonly(isReady),
    isLoading: readonly(isLoading),
    videoInfo: readonly(videoInfo),
    loadVideo,
    play,
    pause,
    seek,
    nextFrame,
    prevFrame
  }
}```

```html
<template>
  <div class="player-container">
    <h1>Vue 3 + WebCodecs 播放器修复版</h1>

    <div class="file-loader">
      <label for="fileInput" class="upload-btn">
        选择 MP4 视频 (H.264)
        <input type="file" id="fileInput" accept="video/mp4" @change="handleFileChange" :disabled="isLoading" />
      </label>
      <div v-if="isLoading" class="loading-status">正在解析和解码...</div>
    </div>

    <div v-if="videoInfo" class="player-main">
      <div class="canvas-wrapper">
        <canvas ref="canvasRef"></canvas>
      </div>

      <div class="controls">
        <div class="info-row">
          <span>分辨率: {{ videoInfo.width }}x{{ videoInfo.height }}</span>
          <span>FPS: {{ videoInfo.fps.toFixed(2) }}</span>
          <span>Codec: {{ videoInfo.codec }}</span>
          <span>缓存帧数: {{ frames.length }}</span>
        </div>

        <div class="progress-bar">
          <input type="range" :min="frames.length > 0 ? frames[0].index : 0"
            :max="frames.length > 0 ? frames[frames.length - 1].index : 0" :value="currentFrameIndex"
            @input="handleSeek" />
        </div>

        <div class="buttons">
          <button @click="prevFrame">上一帧</button>
          <button @click="togglePlay">{{ isPlaying ? '暂停' : '播放' }}</button>
          <button @click="nextFrame">下一帧</button>
        </div>
        <p>帧索引: {{ currentFrameIndex }}</p>
      </div>
    </div>

    <div v-else class="placeholder">
      <p>请选择视频文件以开始</p>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, watch } from 'vue'
import { useVideoFramePlayer } from '@/hooks/useVideoFramePlayer' // 确保路径正确

const canvasRef = ref<HTMLCanvasElement | null>(null)

const {
  frames,
  currentFrameIndex,
  isPlaying,
  isLoading,
  videoInfo,
  loadVideo,
  play,
  pause,
  seek,
  nextFrame,
  prevFrame
} = useVideoFramePlayer()

const handleFileChange = (event: Event) => {
  const input = event.target as HTMLInputElement
  if (input.files && input.files[0]) {
    loadVideo(input.files[0])
  }
}

const togglePlay = () => {
  if (isPlaying.value) pause()
  else play()
}

const handleSeek = (event: Event) => {
  const target = event.target as HTMLInputElement
  seek(Number(target.value))
}

// 渲染函数
const renderFrame = () => {
  if (!canvasRef.value) return

  // 在缓存中找到当前索引对应的帧
  const frameInfo = frames.value.find(f => f.index === currentFrameIndex.value)

  const ctx = canvasRef.value.getContext('2d')
  if (!ctx) return

  if (frameInfo) {
    const frame = frameInfo.frame
    // 如果 Canvas 尺寸不对,调整尺寸
    if (canvasRef.value.width !== frame.codedWidth || canvasRef.value.height !== frame.codedHeight) {
      canvasRef.value.width = frame.codedWidth
      canvasRef.value.height = frame.codedHeight
    }
    ctx.drawImage(frame, 0, 0)
  } else {
    // 可能是 Seek 到了还没解码的地方,或者被清理了
    // 可以在这里画一个 loading 或者保持上一帧
    // console.log('等待帧...', currentFrameIndex.value)
  }
}

// 1. 监听索引变化
watch(currentFrameIndex, renderFrame)

// 2. 关键修复:监听 frames 数组长度变化
// 为什么?因为当第 0 帧解码完成时,currentFrameIndex 依然是 0,上面的 watch 不会触发。
// 监听 frames 变化可以确保一旦有新帧(特别是当前需要的帧)进入,就尝试渲染。
watch(() => frames.value.length, () => {
  renderFrame()
})

</script>

<style scoped>
.player-container {
  max-width: 800px;
  margin: 0 auto;
  text-align: center;
  font-family: sans-serif;
}

.file-loader {
  margin: 20px 0;
}

.upload-btn {
  background: #4caf50;
  color: white;
  padding: 10px 20px;
  border-radius: 4px;
  cursor: pointer;
  display: inline-block;
}

.upload-btn input {
  display: none;
}

.canvas-wrapper canvas {
  max-width: 100%;
  border: 1px solid #ccc;
  background: black;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}

.info-row {
  display: flex;
  justify-content: space-between;
  padding: 10px;
  background: #f5f5f5;
  border-radius: 4px;
  font-size: 0.9em;
  margin-bottom: 10px;
}

.progress-bar input {
  width: 100%;
}

.buttons button {
  padding: 8px 16px;
  margin: 0 5px;
  cursor: pointer;
  background: #2196f3;
  color: white;
  border: none;
  border-radius: 4px;
}

.buttons button:disabled {
  background: #ccc;
}
</style>
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值