1. 背景介绍
1.1 业务背景
使用H.265编码传输视频被广泛应用到业务场景中,原有的 flvjs 显得特别无能为力。 H.265编码视频相较于H.264编码视频,改善码流、编码质量、延时和算法复杂度,因此支持H.265编码视频播放刻不容缓。
2. 播放器概述
播放器的核心还是在于解析视频流,最早的调研方案是用FFmpeg,当一路踩坑好不容易把 C 代码编译成 JavaScript 代码后,发现代码体积过大。在寻找解决方案的过程中,看到了GitHub上的一个开源项目WXInlinePlayer,最终选择了OpenH264和de265作为解码依赖。
2.1 播放FLV视频的交互流程
播放H.265和H.264编码视频的正常交互流程。
2.2 播放HLS视频的交互流程
播放H.264编码视频的正常交互流程。
2.3 播放器内部架构
2.4 播放器整体架构
3. 播放器实现
3.1 核心模块实现
业界比较成熟的解析视频流的方案大多是用 C 语言开发,如果要在浏览器端使用他们,就需要用到一种技术 WebAssembly,并且使用 emsdk(emscripten 1.38.45) 编译 C 代码至 JavaScript 代码。当我们在浏览器端运行编译后的 JavaScript 代码,浏览器端将具备解析和处理H.265编码视频流的能力。
3.1.1 音频播放功能实现
实现音频播放功能,需要使用浏览器提供的 AudioContext 相关API
3.1.1.1 创建 AudioContext 实例
const audioCtx = new AudioContext(); // 创建实例
const gainNode = audioCtx.createGain(); // 用于控制音量
gainNode.connect(audioCtx.destination);
gainNode.gain.value = 0.5; // 设置音量
3.1.1.2 将 AAC 音频流转换成 AudioContext 需要的 buffer
使用
decodeAudioData方法转换 buffer,需要注意顺序问题,调用decodeAudioData的顺序跟回调函数执行的顺序可能不一致,需要用到时间戳做顺序控制
appendBuffer(buf: ArrayBuffer, timestamp: number): void {
const { store_ } = this;
store_.hold(timestamp);
this.audioCtx.decodeAudioData(buf, (buffer: AudioBuffer) => {
switch (this.audioState) {
case AUDIO_WAITING: {
store_.put(timestamp, buffer);
const nextBuf = store_.shift();
if (nextBuf) {
this.playAudio_(nextBuf);
}
break;
}
case AUDIO_ENDED:
case AUDIO_STOPPED:
case AUDIO_DISPOSED:
break;
default:
this.store_.put(timestamp, buffer);
}
}, (e) => {
this.store_.remove(timestamp);
logger.error(`timestamp: ${timestamp}`, e);
});
}
3.1.1.3 实时播放音频流
使用
AudioBufferSourceNode实例播放之前解码后的AudioBuffer
playAudio_(buffer: AudioBuffer): void {
const source: AudioBufferSourceNode = this.audioCtx.createBufferSource();
source.onended = () => {
source.disconnect();
// 获取下一次待播放的 AudioBuffer
// ...
this.playAudio_(nextBuffer);
};
source.buffer = buffer;
source.connect(this.gainNode);
source.start();
}
3.1.1.4 音频队列实现
由于播放过程中对 buffer 的存取操作比较频繁,因此使用了链表实现队列而不是 原生数组
interface Item {
value: any;
next: any;
prev: any;
}
function createItem(data: any): Item {
return { value: data, next: null, prev: null };
}
class Queue {
private first_: Item | null = null;
private last_: Item | null = null;
private length_ = 0;
get first() {
const { first_ } = this;
return first_ && first_.value;
}
get last() {
const { last_ } = this;
return last_ && last_.value;
}
get length() {
return this.length_;
}
clear(): void {
this.length_ = 0;
this.first_ = null;
this.last_ = null;
}
push(o: any): number {
const newLast = createItem(o);
if (this.length_ === 0) {
this.first_ = newLast;
this.last_ = newLast;
}
else {
const { last_: oldLast } = this;
oldLast!.next = newLast;
newLast.prev = oldLast;
this.last_ = newLast;
}
return (++this.length_);
}
shift(): any {
const oldFirst = this.first_;
if (!oldFirst) {
return null;
}
this.length_--;
const newFirst = oldFirst.next;
this.first_ = newFirst;
if (newFirst) {
newFirst.prev = null;
}
else {
this.last_ = null;
}
return oldFirst.value;
}
remove(o: any): number {
const { first_, last_ } = this;
if (first_ === null || last_ === null) {
return -1;
}
if (o === first_.value) {
this.first_ = first_.next;
this.length_--;
return 0;
}
if (o === last_.value) {
this.last_ = last_.prev;
return (--this.length_);
}
for (let i = 1, len = this.length_, prev = first_, current = first_.next; i < len; i++) {
if (current.value === o) {
prev.next = current.next;
this.length_--;
return i;
}
prev = current;
current = current.next;
}
return -1;
}
}
音频解码过程是一个异步过程,解码后进入回调的顺序不一定按照调用顺序进入,因此还需要构造一个 store 来管理,利用时间戳控制顺序存取
class AudioBufferStore {
private map_: {[key: number]: AudioBuffer} = Object.create(null);
private readonly keys_: Queue = new Queue();
hold(timestamp: number): void {
this.keys_.push(timestamp);
}
put(timestamp: number, buf: AudioBuffer): void {
this.map_[timestamp] = buf;
}
shift(): AudioBuffer | null {
const { first } = this.keys_;
if (first === null) {
return null;
}
const firstBuf = this.map_[first];
if (!firstBuf) {
return null;
}
this.remove(first);
return firstBuf;
}
clear(): void {
this.keys_.clear();
this.map_ = Object.create(null);
}
remove(timestamp: number): void {
this.keys_.remove(timestamp);
delete this.map_[timestamp];
}
}
3.1.2 图像渲染功能实现
创建 webgl 上下文实例在 canvas 标签上实时画图
3.1.2.1 创建 webgl 实例
const validContextNames = [
'webgl',
'experimental-webgl',
'moz-webgl',
'webkit-3d'
];
export function getWebGLContext(canvas: HTMLCanvasElement): WebGLRenderingContext | null {
let gl: WebGLRenderingContext | null = null;
let nameIndex = 0;
while (!gl && nameIndex < validContextNames.length) {
const contextName = validContextNames[nameIndex];
try {
gl = canvas.getContext(contextName) as WebGLRenderingContext;
}
catch (e) {
gl = null;
}
if (!gl || typeof gl.getParameter !== 'function') {
gl = null;
}
++nameIndex;
}
return gl;
}
3.1.2.2 渲染图像
渲染的核心就是先从整段 YUV buffer 中依次截取这 3 段 buffer 并排版统一渲染,可参考开源组件 yuv-view
render(data: Uint8Array): void {
const gl = this.webglContext_;
if (!gl) {
console.warn('The WebGLRenderingContext instance was not created');
return;
}
const { width_, height_ } = this;
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
gl.viewport(0, 0, width_, height_);
const i420Data = data;
const yDataLength = width_ * height_;
const yData = i420Data.subarray(0, yDataLength);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.yTexture_);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.LUMINANCE,
width_,
height_,
0,
gl.LUMINANCE,
gl.UNSIGNED_BYTE,
yData
);
const cbDataLength = width_ * height_ / 4;
const cbData = i420Data.subarray(yDataLength, yDataLength + cbDataLength);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.uTexture_);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.LUMINANCE,
width_ / 2,
height_ / 2,
0,
gl.LUMINANCE,
gl.UNSIGNED_BYTE,
cbData
);
const crDataLength = cbDataLength;
const crData = i420Data.subarray(
yDataLength + cbDataLength,
yDataLength + cbDataLength + crDataLength
);
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, this.vTexture_);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.LUMINANCE,
width_ / 2,
height_ / 2,
0,
gl.LUMINANCE,
gl.UNSIGNED_BYTE,
crData
);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
3.1.3 日志模块实现
3.1.3.1 定义日志级别
enum LOG_LEVEL {
FATAL = 6,
ERROR = 5,
WARN = 4,
INFO = 3,
DEBUG = 2,
TRACE = 1
}
3.1.3.2 日志类实现
[2022-05-13T11:01:36.591+08:00] [DEBUG] SPI - 调用 play()
注意:在国内通过 getTimezoneOffset 获取到的时差是 -480
function pad(number: number): string | number {
if (number < 10) {
return `0${number}`;
}
return number;
}
function timezone(minutes: number): string {
let prefix = '+';
if (minutes < 0) {
minutes = Math.abs(minutes);
}
else {
prefix = '-';
}
return `${prefix}${pad(Math.floor(minutes / 60))}:${pad(Math.floor(minutes % 60))}`;
}
function dateFormat(date: Date): string {
const timezoneOffset: number = date.getTimezoneOffset();
return `${date.getFullYear()
}-${pad(date.getMonth() + 1)
}-${pad(date.getDate())
}T${pad(date.getHours())
}:${pad(date.getMinutes())
}:${pad(date.getSeconds())
}.${(date.getMilliseconds() / 1000).toFixed(3).slice(2, 5)
}${timezone(timezoneOffset)}`;
}
function logInfo(method: string, loggerName: string, level: string, args: any[]) {
// eslint-disable-next-line no-console
((console as any)[method] || console.log)(
`[${dateFormat(new Date())}] [${level}] ${loggerName} -`,
...args
);
}
export default class Logger {
name: string;
_level = LOG_LEVEL.INFO;
constructor(loggerName: string, level?: number) {
this.name = loggerName;
this.level = level;
}
get level() {
return this._level;
}
set level(level?: number) {
if (level == null) {
level = LOG_LEVEL.INFO;
}
this._level = level;
forOwn(LOG_LEVEL, (val: number, key: string) => {
const method = key.toLocaleLowerCase();
const consoleMethod = method === 'error' ? method : 'log';
(this as any)[method] = level <= val
? (...args: any[]) => {
logInfo(consoleMethod, this.name, key, args);
}
: () => {};
});
}
error() {}
warn() {}
info() {}
debug() {}
}
3.1.3.3 统一设置日志级别
一般情况都是在代码比较靠前的位置创建一个 logger 实例,通过 getLogger 方法创建并放入缓存,便于统一设置日志级别
const loggerCache: {[key: string]: ILogger} = {};
function getLogger(name: string, level?: number): ILogger {
const logger = loggerCache[name];
if (!logger) {
return (loggerCache[name] = new Logger(name, level));
}
return logger;
}
function setLevel(level: number) {
forOwn(loggerCache, (logger: ILogger) => {
logger.level = level;
});
}
获取一个 logger 实例
const logger = getLogger('SPI');
初始化播放器时,统一设置日志级别
setLevel(options.logLevel);
3.1.4 拉流模块实现
获取实时流和文件流
3.1.4.1 获取实时流
获取实时流的不同之处就是建立连接之后,递归地读取响应流直到结束
function cancelStream(stream: ReadableStream): void {
if (!stream.locked) {
stream.cancel();
}
}
function readArrayBuffer(
loader: StreamLoader,
stream: ReadableStream,
reader: ReadableStreamDefaultReader
): Promise<void> {
return reader.read().then((result: ReadableStreamDefaultReadResult<ArrayBuffer>) => {
// 请求已中断
if (loader.state === LOADER_ABORTED) {
cancelStream(stream);
return;
}
// 拉流结束
if (result.done) {
loader.state = LOADER_ENDED;
loader.abortController = null;
loader.emit('end');
logger.debug(`拉流结束 ${loader.currentUrl}`);
return;
}
const data = result.value;
loader.byteLength += data.byteLength;
loader.emit('data', data);
// 继续读取
return readArrayBuffer(loader, stream, reader);
});
}
class StreamLoader extends Emitter {
abortController: AbortController | null = null;
state = LOADER_INIT;
fetchOptions: {[key: string]: any} = {};
type = 'stream';
byteLength = 0;
currentUrl = '';
constructor(opts: StreamLoaderOptions) {
super();
this.initFetchOptions_(opts);
}
fetch(url: string): void {
const params = Object.assign({}, this.fetchOptions);
params.signal = this.getAbortController_().signal;
this.byteLength = 0;
this.state = LOADER_START;
this.currentUrl = url;
// 请求资源
logger.debug('准备请求', url);
fetch(url, params).then((res: Response) => {
const stream: ReadableStream | null = res.body;
// 接口未返回结果
if (!stream) {
return Promise.reject(new Error('服务端未返回结果'));
}
// 异常状态
if (!res.ok || !validateHttpStatus(res.status)) {
return Promise.reject(new Error(`异常状态: ${res.status}`));
}
// 请求已中断
if (this.state === LOADER_ABORTED) {
cancelStream(stream);
return;
}
logger.debug(`开始拉流 ${url}`);
this.state = LOADER_FETCHING;
this.emit('loadstart');
return readArrayBuffer(this, stream, stream.getReader())
.catch((e: Error) => {
// 异常后先把流取消
cancelStream(stream);
// 请求中断异常先忽略
if (this.state === LOADER_ABORTED) {
return;
}
return Promise.reject(e);
});
}).catch((e) => {
// 请求中断异常先忽略
if (this.state === LOADER_ABORTED) {
logger.info(`中断请求:${url}`);
this.emit('aborted');
return;
}
const lastState = this.state;
this.state = LOADER_ERROR;
logger.error('拉流异常', url, e);
// 异常后中断请求
this.abort();
this.emit('error', e, lastState);
});
}
retry(): void {
const { currentUrl } = this;
if (!currentUrl) {
return;
}
this.abort();
logger.debug('重新请求:', currentUrl);
this.fetch(currentUrl);
}
abort(): void {
const { abortController } = this;
if (abortController && !abortController.signal.aborted) {
abortController.abort();
this.state = LOADER_ABORTED;
this.abortController = null;
}
}
initFetchOptions_(opts: StreamLoaderOptions): void {
const params: {[key: string]: any} = {
method: 'GET',
headers: opts.headers,
mode: 'cors',
// mode: 'no-cors',
cache: 'default',
referrerPolicy: 'no-referrer-when-downgrade'
};
if (opts.cors === false) {
params.mode = 'same-origin';
}
if (opts.withCredentials) {
params.credentials = 'include';
}
else if (opts.credentials) {
params.credentials = opts.credentials;
}
if (opts.referrerPolicy) {
params.referrerPolicy = opts.referrerPolicy;
}
this.fetchOptions = params;
}
getAbortController_(): AbortController {
const abortController = new AbortController();
this.abortController = abortController;
return abortController;
}
}
3.1.4.2 获取文件流
获取文件流跟获取实时流类似,区别在于不用递归去读取数据,只需要继承上面的实现类并重写 fetch 方法
class FileLoader extends StreamLoader {
type = 'hls';
fetch(url: string): void {
const params = Object.assign({}, this.fetchOptions);
params.signal = this.getAbortController_().signal;
const dataType = ~url.indexOf('.m3u8') ? 'text' : 'arrayBuffer';
this.byteLength = 0;
this.state = LOADER_START;
this.currentUrl = url;
// 请求资源
logger.debug('准备请求', url);
fetch(url, params)
.then((res: Response) => {
// 异常状态
if (!res.ok || !validateHttpStatus(res.status)) {
return Promise.reject(new Error(`异常状态: ${res.status}`));
}
this.emit('loadstart');
return (res[dataType] as any)();
})
.then((ret: string | ArrayBuffer) => {
if (ArrayBuffer.isView(ret)) {
this.byteLength = ret.byteLength;
}
this.state = LOADER_ENDED;
this.abortController = null;
this.emit('end', ret);
logger.debug(`请求完成 ${url}`);
})
.catch((e) => {
// 请求中断异常先忽略
if (this.state === LOADER_ABORTED) {
logger.info(`中断请求:${url}`);
this.emit('aborted');
return;
}
const lastState = this.state;
this.state = LOADER_ERROR;
logger.error('请求异常', url, e);
// 异常后中断请求
this.abort();
this.emit('error', e, lastState);
});
this.state = LOADER_START;
}
}
3.1.5 解码线程实现
浏览器中不支持多线程,线程实际上是用进程模拟实现,通过对消息订阅相互传递数据
- 解码进程包含四大块:
- wasm 核心解码逻辑
- 桥接解码过程
- 拉 flv 实时流和拉 hls 切片流
- 主进程和解码进程之间互通
3.1.5.1 在主进程中创建解码进程
自定义
rollup插件,匹配bundlify!为前缀的代码文件内部打包,并作为字符串跟主代码合并在一个文件中,因此,我们需要通过以下方式创建Worker
function createWorker(codes: string[], opts?: WorkerOptions): Worker {
const blob = new Blob(codes);
const url = URL.createObjectURL(blob);
const worker = new Worker(url, opts);
worker.objectURL = url;
const terminate = worker.terminate;
worker.terminate = function () {
URL.revokeObjectURL(url);
return terminate.call(this);
};
return worker;
}
import decoderString from 'bundlify!./decoder';
const decoderWorker = createWorker([decoderString], opts);
decoderWorker.onmessage = ({ data }: MessageEvent<DecoderMessage>) => {
switch (data.type) {
case MSG_ENV_READY:
// 已初始化 wasm 环境
break;
case MSG_LOAD:
// 已启动底层解码,尝试下载视频,数据还没有
break;
case MSG_LOAD_START: {
// 连接成功正在下载视频
break;
}
case MSG_DURATION: {
// 视频时长变化
break;
}
case MSG_METADATA: {
// 视频信息
break;
}
case MSG_STATUS: {
const { status } = data;
this.runtimeState = status;
switch (status) {
case RUNTIME_PLAYING:
// 播放中
break;
case RUNTIME_BUFFERING:
// 缓冲中
break;
case RUNTIME_PAUSED:
// 暂停完成
break;
case RUNTIME_ENDED: {
// 播放结束
break;
}
case RUNTIME_ERROR:
// 异常情况
break;
}
break;
}
case MSG_VIDEO_DATA:
// 图像数据
break;
case MSG_AUDIO_DATA:
// 音频数据
break;
case MSG_CURRENT_TIME: {
// 当前时长
break;
}
case MSG_SEEK:
// hls seek 结束
break;
case MSG_CHANGE_RATE:
// hls 变速完成
break;
case MSG_STOP:
// 停止解码
break;
case MSG_FETCH_ERROR:
// 拉流异常
break;
}
};
3.1.5.2 导入 wasm 模块
使用__WASM_DECODER__作为占位符,打包代码时将__WASM_DECODER__替换成核心解码逻辑,解码逻辑是用 C++ 开发并通过 emsdk 编译成 JavaScript
import bridge from './bridge';
import subscription from './subscription';
import { MSG_ENV_READY } from '../constant/messageType';
import logger from './logger';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const Module: any = {
onRuntimeInitialized(): void {
logger.debug('加载解码环境完成');
// C模块已加载
postMessage({ type: MSG_ENV_READY });
}
} as {[key: string]: any};
// eslint-disable-next-line no-unused-expressions
__WASM_DECODER__;
bridge();
subscription();
3.1.5.3 桥接解码过程
这里实际上是定义解码过程中需要回调的函数
(self as any)[Module.bridgeName] = {
onDuration(duration: number): void {
// 接收时长并传给主进程
},
onMetaData(width: number, height: number): void {
// 接收视频信息并传给主进程
},
onStatus(status: number): void {
// 接收解码运行状态并传给主进程
},
onAudio(ptr: number, length: number, timestamp: number/* , delay_ms: number */): void {
// 接收音频数据并传递给主进程
},
onVideo(ptr: number, length: number, timestamp: number/* , delay_ms: number */): void {
// 接收图像数据并传给主进程
},
onGetFile(urlPtr: number, urlLength: number): void {
// 根据解析后的ts地址拉流并传给解码模块
},
onGetFileAndDecrypt(
urlPtr: number,
urlLength: number,
keyPtr: number,
keyLength: number,
ivPtr: number,
ivLength: number): void {
// 根据解析后的ts地址拉流并按照密钥和向量解码后再传给解码模块
},
onUpdateM3U8() {
// hls直播时会一直回调该函数进行重新拉流再传给解码模块
}
};
3.1.5.4 主进程消息订阅
接收主进程的消息进行初始化解码模块、播放、暂停、变速、seek、停止、关闭进程一系列操作
self.onmessage = ({ data }: MessageEvent<DecoderMessage>) => {
switch (data.type) {
case MSG_LOAD:
// 初始化解码模块并拉流
break;
case MSG_CHANGE_RATE:
// 变速
break;
case MSG_PLAY:
// 播放
break;
case MSG_PAUSE:
// 暂停
break;
case MSG_STOP:
// 停止播放
break;
case MSG_SEEK:
// hls seek
break;
case MSG_CLOSE:
// 关闭进程
break;
}
};
3.2 videojs-tech-h265 实现
官网没有详细的 tech 开发说明,可以参考官方在 GITHUB 上的 tech 拓展 videojs-flash
3.2.1 自定义 tech 实现
新建一个 class 继承 videojs 内部的 Tech 并实现部分必要函数
class TechH265 extends Tech {
static formats = {
'video/h265': 'FLV'
};
static isSupported() {
// 判断当前环境是否支持该技术
};
static canPlayType(type: string) {
if (TechH265.isSupported() && type in TechH265.formats) {
return 'maybe';
}
return '';
}
static canPlaySource(
srcObj: {type: string, src: string},
) {
return TechH265.canPlayType(srcObj.type);
}
constructor(options: any, ready: boolean) {
super(options, ready);
// 设置支持变速
this.featuresPlaybackRate = true;
this.name_ = 'TechH265';
// ... 其它自定义处理
// 调用后触发 ready 事件
this.triggerReady();
}
createEl(): HTMLCanvasElement {
// 创建 canvas 元素并返回
const { options_ } = this;
const videoOpts: {[key: string]: any} = {
id: options_.playerId,
logLevel: options_.logLevel,
muted: options_.muted
};
const player = this.getPlayer_();
const video = createVideo(videoOpts);
// ... 其它特殊处理
const { el } = video;
el.setAttribute('id', options_.techId);
el.setAttribute('class', 'vjs-tech');
return el;
}
setScrubbing(scrubbing: boolean): void {
// 拖动播放器进度条时 scrubbing 的值为 true,结束时为 false
}
setCurrentTime(seconds: number): void {
// 拖动播放器进度条和手动设置 currentTime 时触发,
// 当拖动播放器进度条时,setScrubbing 的调用时机早于当前函数
}
setPlaybackRate(rate: number): void {
// 重置和切换倍速时被调用
// 触发 ratechange 事件后,界面上才会同步修改
this.trigger('ratechange');
}
setVolume(volume: number): void {
// 设置音量
// 触发 volumechange 事件后,界面上才会同步修改
this.trigger('volumechange');
}
setMuted(muted: boolean) {
// 是否静音
// 触发 volumechange 事件后,界面上才会同步修改
this.trigger('volumechange');
}
src(src: string): void {
// 设置播放源
}
load(): void {
// 加载播放源
}
seekable(): any {
const duration = this.duration();
if (duration === 0) {
return createTimeRange();
}
return createTimeRange(0, duration);
}
controls(): boolean {
// 是否使用本地播放器控制条
return false;
}
defaultPlaybackRate(): number {
// 如果属性 featuresPlaybackRate 设置为true,当前函数一定要定义
return 1;
}
supportsFullScreen(): boolean {
// 是否支持全屏
}
enterFullScreen(): void {
// 进入全屏模式
}
dispose(): void {
// 销毁播放器时回调
}
play(): void {
// 播放
}
pause(): void {
// 暂停
}
reset(): void {
// 重置播放器时触发
}
currentTime(): number {
// 获取当前时间,秒为单位
}
duration(): number {
// 获取播放时长
},
ended(): boolean {
// 是否结束
}
muted(): boolean {
// 是否静音
}
networkState(): number {
// 参考 HTMLMediaElement 的 networkState
}
paused(): boolean {
// 是否已暂停
}
playbackRate(): number {
// 获取倍速值
}
readyState(): number {
// 参考 HTMLMediaElement 的 readyState
}
seeking(): boolean {
// 是否正在 seek
}
volume(): number {
// 获取音量大小
}
autoplay(bool?: boolean): boolean | void {
// 设置自动播放或者获取该值
}
}
3.2.2 注册 Tech
videojs.registerTech('h265', TechH265);
3.2.3 使用 Tech
// 初始化播放器
const player = videojs(el, {
techOrder: ['h265']
});
player.src({
src: '播放地址',
type: 'video/h265',
});
player.autoplay(true);
player.load();
3.3 遗留问题
播放器虽然已经对内和对外使用,但还存在以下几个问题:
3.3.1 音频播放延迟问题
使用 AudioBufferSourceNode播放音频时,需要一定量的 buffer 才能播放,因此暂时处理为把每 32 帧音频做了合并,作为一段可播放的 buffer 造成延时,具体延迟的时间不定跟视频有关,后续会设计成按时间合并,具体规则还在思考中。
3.3.2 音频倍速播放问题
使用 AudioBufferSourceNode设置playbackRate属性变速播放音频时,除了播放速度有改变,音调也发生了变化,通过自定义AudioWorkletProcessor处理也没能达到预期,还在摸索中。
3.3.3 性能问题
多倍速解码 2K 视频流和解码 4K 视频流存在性能瓶颈,目前正在尝试多线程解码,但是多线程解码依赖于浏览器需要支持SharedArrayBuffer,页面通过开启coep和coop策略支持SharedArrayBuffer,同时又影响到跨域资源的加载,尤其是加载cdn资源。
4. 总结
本次只实现了播放内核和 videojs 插件,不算一个完整的播放器,UI这层还是依赖于 videojs。对于播放内核的实现也总结了以下几个容易上手的知识点:
4.1 知识点 - WebAssembly
WebAssembly 的简称是 wasm,使用 emsdk 编译 C 语言代码至 wasm文件 和 JavaScript 文件,跟前端业务代码融为一体,实现最终的业务需求
4.1.1 实战演练
实现 JavaScript 调用 C 函数,和 C 函数调用 JavaScript 的过程
使用 vscode 开发 C 语言前需要在项目的根目录添加一个配置文件.vscode/c_cpp_properties.json (详情可参考 官网 ),防止编辑器提示找不到emscripten.h
{
"env": {
"myDefaultIncludePath": [
"/Users/jesse/emsdk/fastcomp/emscripten/system/include/**",
"${workspaceFolder}/**"
]
},
"configurations": [
{
"name": "Mac",
"includePath": [
"${myDefaultIncludePath}"
],
"browse": {
"path": [
"${workspaceFolder}"
],
"limitSymbolsToIncludedHeaders": true,
"databaseFilename": ""
},
"defines": [],
"macFrameworkPath": [
"/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/System/Library/Frameworks"
],
"compilerPath": "/usr/bin/clang",
"cStandard": "c11",
"cppStandard": "c++17",
"intelliSenseMode": "clang-x64"
}
],
"version": 4
}
新建一个 main.c
#include <emscripten.h>
EMSCRIPTEN_KEEPALIVE
int sum(int a, int b)
{
return a + b;
}
EMSCRIPTEN_KEEPALIVE
int call_js_sum(int a, int b)
{
return EM_ASM_INT({ return js_sum($0, $1); }, a, b);
}
新建一个 index.html
<html>
<head>
<title>hello wasm</title>
<style>
section {
font-size: 20px;
}
#success {
color: #ff0000;
}
</style>
</head>
<body>
<script src="./main.js"></script>
<script>
function $(selector) {
return document.querySelector(selector);
}
Module.onRuntimeInitialized = () => {
$('#success').innerHTML = "wasm 加载成功";
};
function js_sum(a, b) {
return a + b;
}
function onSum(a, b) {
$('#result').innerHTML = Module._sum(a, b);
}
function onSum2(a, b) {
$('#result2').innerHTML = Module._call_js_sum(a, b);
}
</script>
<section id="success"></section>
<section id="error"></section>
<section>
<span>1 + 1</span>
<button onclick="onSum(1,1);">=</button>
<span id="result"></span>
</section>
<br />
<section>
<span>1 + 2</span>
<button onclick="onSum2(1,2);">=</button>
<span id="result2"></span>
</section>
</body>
</html>
新建一个 Makefile 文件
input = ${shell pwd}/main.c
output = ${shell pwd}/main.js
all: hello-wasm
hello-wasm:
emcc ${input} -s WASM=1 -o ${output}
使用 make 命令编译 main.c
$ make hello-wasm
emcc /Users/jesse/workspace/hello-wasm/main.c -s WASM=1 -o /Users/jesse/workspace/hello-wasm/main.js
启动本地服务加载 html 资源查看最终效果
4.2 知识点 - AudioContext
AudioContext 是音频处理的一个上下文环境,在处理或播放音频之前,都需要创建一个 AudioContext 实例,并且可以全局共享同一个。(音频测试文件 test.aac)
4.2.1 实战演练
实现播放 AAC 音频文件
<html>
<head>
<title>hello audio</title>
<style>
section {
font-size: 20px;
}
</style>
</head>
<body>
<script>
const audioCtx = new AudioContext();
const gainNode = audioCtx.createGain();
gainNode.gain.value = 1;
gainNode.connect(audioCtx.destination);
function play() {
fetch('/test.aac')
.then((res) => res.arrayBuffer())
.then((buf) => {
audioCtx.decodeAudioData(buf, (audioBuf) => {
const source = audioCtx.createBufferSource();
source.buffer = audioBuf;
source.connect(gainNode);
source.start();
});
});
}
</script>
<section>
<button onclick="play();">播放</button>
</section>
</body>
</html>
启动本地服务加载 html 资源查看最终效果
4.3 知识点 - WebGL
WebGL是一个JavaScript API,可在任何兼容的Web浏览器中渲染高性能的交互式3D和2D图形,而无需使用插件。WebGL提供了在浏览器中创建具有桌面应用体验的应用的最终特性。
4.3.1 实战演练
实现用 WebGL 的方式在 canvas 上绘制一个点
- 绘制一个点有以下几步:
- 获取
canvas元素 - 获取 WebGL 绘图上下文
WebGLRenderingContext - 初始化着色器
- 设置
canvas的背景色 - 最后再通过 drawArrays 绘制点
- 获取
- 潜在的点:
- 创建着色器用到的代码片段是基于 GLSL(OpenGL Shading Language)
gl_Position必须被赋值并且是vec4类型(vec4(X, Y, Z, 1.0))gl_FragColor用于控制绘制点的颜色并且是vec4类型(vec4(R, G, B, A)),但是R、G、B这3个分量的取值范围是0到1的浮点数,因此在指定颜色值时需要除以255
<html>
<head>
<title>hello webgl</title>
</head>
<body>
<button onclick="drawPoint()">画点</button>
<br/>
<canvas id="webgl" width="400" height="400">
<script>
const names = ['webgl', 'experimental-webgl', 'webkit-3d', 'moz-webgl'];
// 创建 webgl 上下文实例
function getWebGLContext(canvas, opts) {
let context = null;
for (var ii = 0; ii < names.length; ++ii) {
try {
context = canvas.getContext(names[ii], opts);
} catch (e) { }
if (context) {
break;
}
}
return context;
}
// 顶点着色器脚本(控制坐标和尺寸)
const vShaderScript = `
void main() {
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
gl_PointSize = 20.0;
}
`;
// 片元着色器脚本(控制颜色)
const fShaderScript = `
void main() {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
`;
// 加载着色器
function loadShader(gl, type, source) {
// 创建着色器
const shader = gl.createShader(type);
if (shader == null) {
console.warn('unable to create shader');
return null;
}
// 设置着色器和脚本
gl.shaderSource(shader, source);
// 编译着色器
gl.compileShader(shader);
// 检查编译结果
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.warn('Failed to compile shader: ' + gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
// 创建着色器程序
function createProgram(gl, vshader, fshader) {
// 创建顶点着色器
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vshader);
// 创建片元着色器
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fshader);
if (!vertexShader || !fragmentShader) {
return null;
}
// 创建着色器程序 https://developer.mozilla.org/zh-CN/docs/Web/API/WebGLRenderingContext/createProgram
const program = gl.createProgram();
if (!program) {
return null;
}
// 添加预先定义好的顶点着色器和片元着色器
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
// 关联着色器
gl.linkProgram(program);
// 检查编译状态
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
var error = gl.getProgramInfoLog(program);
console.warn('Failed to link program: ' + error);
gl.deleteProgram(program);
gl.deleteShader(fragmentShader);
gl.deleteShader(vertexShader);
return null;
}
return program;
}
// 初始化着色器
function initShaders(gl, vshader, fshader) {
const program = createProgram(gl, vshader, fshader);
if (!program) {
console.warn('Failed to create program');
return false;
}
gl.useProgram(program);
return true;
}
const gl = getWebGLContext(document.getElementById('webgl'));
function drawPoint() {
if (!initShaders(gl, vShaderScript, fShaderScript)) {
console.warn('Failed to intialize shaders.');
return;
}
// 预置背景色
// (1.0, 0.0, 0.0, 1.0) 红色
// (0.0, 1.0, 0.0, 1.0) 绿色
// (0.0, 0.0, 1.0, 1.0) 蓝色
// (1.0, 1.0, 0.0, 1.0) 黄色
// (1.0, 0.0, 1.0, 1.0) 紫色
// (0.0, 1.0, 1.0, 1.0) 青色
// (1.0, 1.0, 1.0, 1.0) 白色
gl.clearColor(0.0, 0.0, 0.0, 1.0); // 黑色
// 设置背景色
gl.clear(gl.COLOR_BUFFER_BIT);
// 画圆点 https://developer.mozilla.org/zh-CN/docs/Web/API/WebGLRenderingContext/drawArrays
gl.drawArrays(
gl.POINTS, // 绘制一系列点
0, // 从第0个点开始绘制
1 // 绘制需要用到1个点
);
}
</script>
</body>
</html>
启动本地服务加载 html 资源查看最终效果
4.4 知识点 - Web Worker
Web Worker 是浏览器为了脚本在后台线程中运行提供了一种简单的方法,并且执行任务时不干扰用户界面。
Web Worker 的作用就是为 JavaScript 创造多线程环境,允许主进程创建 Worker 进程,将一些任务分配给后者运行。在主进程运行的同时,Worker 进程在后台运行,两者互不干扰。等到 Worker 进程完成计算任务,再把结果返回给主进程。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 进程负担了,主进程(如:UI 交互)就会比较流畅,不会被阻塞或拖慢。
4.4.1 实战演练
实现简单的主、子进程之间的数据互通。
注意:postMessage 传输的内容中包含 TypedArray,需要将它的 buffer 转移给接收方用于提高性能,如:
postMessage(uint8, [uint8.buffer])
<html>
<head>
<title>hello worker</title>
<style>
section {
font-size: 20px;
}
#success {
color: #ff0000;
}
</style>
</head>
<body>
<script>
function $(selector) {
return document.querySelector(selector);
}
function createWorker(codes, opts) {
const blob = new Blob(codes);
const url = URL.createObjectURL(blob);
const worker = new Worker(url, opts);
const terminate = worker.terminate;
worker.terminate = function () {
URL.revokeObjectURL(url);
return terminate.call(this);
};
return worker;
}
function workerCode() {
self.onmessage = ({ data }) => {
switch(data.type) {
case 'sum':
postMessage({
type: 'sum',
payload: data.a + data.b
});
break;
}
}
postMessage({
type: 'loaded'
});
}
const worker = createWorker([`(${workerCode.toString()})()`]);
worker.onmessage = ({ data }) => {
switch(data.type) {
case 'loaded':
$('#success').innerHTML = "初始化Worker成功";
break;
case 'sum':
$('#result').innerHTML = data.payload;
break;
}
};
function onSum(a, b) {
worker.postMessage({type: 'sum', a, b});
}
</script>
<section id="success"></section>
<section id="error"></section>
<section>
<span>1 + 1</span>
<button onclick="onSum(1,1);">=</button>
<span id="result"></span>
</section>
</body>
</html>
启动本地服务加载 html 资源查看最终效果