webcodecs解码视频demo
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
}
const fps = videoTrack.nb_samples > 0
? (videoTrack.nb_samples / ((videoTrack.duration / videoTrack.timescale) || 1))
: 30
onReadyInfo({...info, fps })
let description: ArrayBuffer | undefined
const trak = mp4boxfile.getTrackById(videoTrack.id)
const entry = trak?.mdia?.minf?.stbl?.stsd?.entries[0] as MP4BoxEntry
const codecBox = entry.avcC || entry.hvcC
if (codecBox) {
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)
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
}
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)
})
}
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
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) {
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
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 {
}
}
watch(currentFrameIndex, renderFrame)
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>