📉 前言:浏览器对 H.265 的'爱恨情仇'
为什么 <video src="video.h265.mp4"> 在 Chrome 里放不出来?因为 H.265 的专利池太深了。只有 Safari (即使是 iOS) 和 Edge (需硬件支持) 原生支持较好。
我们的目标是构建一套混合解码方案:
- 优先硬解 (WebCodecs):如果浏览器支持硬件加速(如 Chrome 94+ 的 WebCodecs),直接调用 GPU,性能起飞。
- 降级软解 (Wasm + FFmpeg):如果不支持,自动切换到 WebAssembly 版的 FFmpeg 进行 CPU 软解,利用 SIMD 指令集加速。
播放器架构逻辑:
- 方案 A: 硬件解码 (Yes -> GPU 解码)
- 方案 B: 软件解码 (No -> Wasm 指令 -> CPU 解码)
- 流程:视频流 (H.265/HEVC) -> 解封装 (Demuxer) -> Encoded Packets -> [浏览器支持 WebCodecs?] -> WebCodecs API (VideoDecoder) / FFmpeg (Wasm + SIMD) -> VideoFrame 对象 / YUV420 数据 -> Canvas (WebGL)
🛠️ 一、编译 FFmpeg 为 WebAssembly
这是最困难的一步。我们需要使用 Emscripten 将 C 语言编写的 FFmpeg 编译成 .wasm 文件。
关键编译参数: 为了性能,必须开启 Multithreading (多线程) 和 SIMD (单指令多数据流)。
# Docker 环境下编译示例
emcc \
-Llibavcodec -Llibavutil -Llibswscale \
-I. \
-o ffmpeg-decoder.js \
src/decoder.c \
-s WASM=1 \
-s USE_PTHREADS=1 \
# 开启多线程
-s PTHREAD_POOL_SIZE=4 \
# 预分配线程池
-s SIMD=1 \
# 开启 SIMD 加速 (关键!)
-s ALLOW_MEMORY_GROWTH=1 \
-O3 # 最高优化等级
*注意:src/decoder.c 是你需要编写的 C 语言胶水代码,用于暴露 FFmpeg 的 avcodec_send_packet 和 avcodec_receive_frame 接口给 JS 调用。
🧬 二、核心实现:Web Worker 中的解码循环
解码是 CPU 密集型任务,绝对不能放在主线程,否则页面会卡死。我们需要在 Web Worker 中运行 Wasm。
1. 初始化解码器 (Worker.js)
importScripts('ffmpeg-decoder.js');
let decoderModule;
let codecContext;
// 初始化 Wasm 模块
Module().then(module => {
decoderModule = module;
// 调用 C 导出的初始化函数
codecContext = decoderModule._init_h265_decoder();
postMessage({ type: 'ready' });
});
self.onmessage = function(e) {
const { type, data } = e.data;
if (type === 'decode') {
// data 是包含 H.265 NALU 的 Uint8Array
// 1. 将数据写入 Wasm 内存 heap
const ptr = decoderModule._malloc(data.length);
decoderModule.HEAPU8.set(data, ptr);
// 2. 调用解码
// decode_frame 是 C 层封装的函数
const ret = decoderModule._decode_frame(codecContext, ptr, data.length);
// 3. 获取 YUV 数据并传回主线程
if (ret === 0) {
// 从 Wasm 内存拷贝 Y, U, V 数据
// 注意:使用 Transferable Objects (零拷贝) 提升性能
const yuvData = getYUVFromWasm();
postMessage({ type: 'render', frame: yuvData }, [yuvData.buffer]);
}
decoderModule._free(ptr);
}
};
🎨 三、高性能渲染:WebGL 处理 YUV
FFmpeg 解码出来的数据通常是 YUV420p 格式。 不要在 CPU 里把 YUV 转 RGB(这非常慢),要用 WebGL Shader 在 GPU 里转!
渲染流程:
- 创建 3 个 WebGL 纹理 (Texture),分别存放 Y、U、V 数据。
- 编写 Fragment Shader 进行矩阵转换。
Fragment Shader (GLSL):
precision mediump float;
uniform sampler2D textureY;
uniform sampler2D textureU;
uniform sampler2D textureV;
varying vec2 vTexCoord;
void main() {
float y = texture2D(textureY, vTexCoord).r;
float u = texture2D(textureU, vTexCoord).r - 0.5;
float v = texture2D(textureV, vTexCoord).r - 0.5;
// YUV 转 RGB 公式 (BT.601)
float r = y + 1.402 * v;
float g = y - 0.34414 * u - 0.71414 * v;
float b = y + 1.772 * u;
gl_FragColor = vec4(r, g, b, 1.0);
}
⏱️ 四、难点攻克:音画同步 (AV Sync)
视频能播了,但声音和画面对不上怎么办?通常以 音频时钟 (Audio Clock) 为基准。
同步逻辑:
- PTS < AudioTime (视频慢了) -> 丢帧追赶
- PTS > AudioTime (视频快了) -> 等待下一帧绘制
- PTS ≈ AudioTime (刚好) -> 渲染到 Canvas
在 JS 主线程中:
function renderLoop() {
const audioTime = audioContext.currentTime;
const frame = frameBuffer[0]; // 获取队列头部的帧
if (!frame) return requestAnimationFrame(renderLoop);
const diff = frame.pts - audioTime;
if (diff < -0.03) {
// 视频落后超过 30ms -> 丢帧追赶
frameBuffer.shift();
renderLoop();
} else if (diff > 0.03) {
// 视频超前 -> 等待下一帧绘制
requestAnimationFrame(renderLoop);
} else {
// 同步 -> 渲染
drawYUV(frame);
frameBuffer.shift();
requestAnimationFrame(renderLoop);
}
}
📊 五、性能优化清单
为了达到 1080p 甚至 4K 的流畅播放,以下优化必不可少:
- 开启 SIMD:在支持 SIMD 的浏览器上,软解性能提升 2-3 倍。
- SharedArrayBuffer:在主线程和 Worker 之间共享内存,避免数据拷贝开销(需要配置 HTTP Header:
Cross-Origin-Opener-Policy: same-origin)。 - OffscreenCanvas:将 Canvas 的控制权转移给 Worker,让渲染也在 Worker 线程完成,彻底解放主线程 UI。
- WebCodecs 优先:始终检测
VideoDecoderAPI。如果支持硬件解码,直接 bypass 掉 Wasm 模块,这是性能的降维打击。
🎯 总结
通过 Wasm + FFmpeg + WebGL,我们填补了浏览器 H.265 支持的空白。虽然软解 4K 依然吃力(主要受限于单线程 JS 调度和 CPU 算力),但在 720p/1080p 监控流、会议流场景下,这是一套成熟且工业级的解决方案。
Next Step: 现在的方案是基于现成 MP4 文件的。尝试结合 WebSocket 或 WebRTC,接收实时的 H.265 NALU 流(如 RTSP 转 WS),实现一个低延迟的网页版安防监控播放器。


