家好,很高兴又见面了,我是"高前端?进阶?",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发!
高前端?进阶
现代技术提供了丰富的视频处理方式,比如 Media Stream API、Media Recording API、Media Source API 和 WebRTC API 共同组成了一个用于录制、传输和播放视频流的丰富工具集。
在Chrome 94+版本上已经支持了WebCodecs!
在解决某些高级任务时,这些 API 不允许 Web 开发者处理视频流的各个组成部分,例如帧、未混合的编码视频或音频块。 为了获得对这些基本组件的底层访问,开发人员一直在使用 WebAssembly 将视频和音频编解码器引入浏览器。 但鉴于现代浏览器已经附带了各种编解码器,将它们重新打包为 WebAssembly 似乎是对人力和计算机资源的浪费。
WebCodecs API 为开发者提供了一种使用浏览器中已经存在的媒体组件的方法,从而提高了效率。 具体包括以下部分:
WebCodecs API 对于需要完全控制媒体内容处理方式的 Web 应用程序非常有用,例如视频编辑器、视频会议、视频流等。
帧是视频处理的核心。 因此,在 WebCodecs 中,大多数类要么消费帧,要么生产帧。 视频编码器将帧转换为编码块,而视频解码器则相反。可以通过如下方法判断浏览器是否支持WebCodecs:
if (window.isSecureContext) {
// 页面上下文完全,同时serviceWorker加载完成
navigator.serviceWorker.register("/offline-worker.js").then(()=> {
});
}
if ('VideoEncoder' in window) {
// 支持WebCodecs API
}
请记住,WebCodecs 仅在安全上下文中可用,因此如果 self.isSecureContext 为 false,检测将失败!
VideoFrame 通过成为 CanvasImageSource 并具有接受 CanvasImageSource 的构造函数,可以很好地与其他 Web API 配合使用。 所以它可以用在 drawImage() 和 texImage2D() 等函数中。 它也可以由画布、位图、视频元素和其他视频帧构建。VideoFrame的构造函数如下:
new VideoFrame(image)
new VideoFrame(image, options)
new VideoFrame(data, options)
// 第二个参数为配置对象
下面是VideoFrame的一个典型示例:
const pixelSize=4;
const init={timestamp: 0, codedWidth: 320, codedHeight: 200, format: 'RGBA'};
let data=new Uint8Array(init.codedWidth * init.codedHeight * pixelSize);
for (let x=0; x < init.codedWidth; x++) {
for (let y=0; y < init.codedHeight; y++) {
let 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
}
}
let frame=new VideoFrame(data, init);
WebCodecs API 与 Insertable Streams API 中的类协同工作,后者将 WebCodecs 连接到媒体流轨道(Media Stream Tracks)。
MediaStreamTrack 接口表示流中的单个媒体轨道;通常,这些是音频或视频轨道,但也可能存在其他轨道类型。
根据设计,WebCodecs API 异步完成所有繁重的工作并脱离主线程。 但是由于框架和块回调通常可以每秒调用多次,它们可能会使主线程混乱,从而降低网站的响应速度。 因此,最好将单个帧和编码块的处理转移到Web Worker中。
为此,ReadableStream 提供了一种方便的方法来自动将来自媒体轨道的所有帧传输到工作程序。 例如,MediaStreamTrackProcessor 可用于获取来自网络摄像头的媒体流轨道的 ReadableStream。 之后,流被传输到Web Worker,其中帧被一个一个地读取并排队进入 VideoEncoder。
Streams API 的 ReadableStream 接口表示字节数据的可读流。 Fetch API 通过 Response 对象的 body 属性提供了 ReadableStream 的具体实例。
使用
HTMLCanvasElement.transferControlToOffscreen 甚至可以在主线程之外完成渲染。如果所有高级工具都不符合要求,VideoFrame 本身是可转移的,可以在Web Worker之间移动。
这一切都始于 VideoFrame,可以通过三种方式构建视频帧。
const canvas=document.createElement("canvas");
// 在Canvas中绘制
const frameFromCanvas=new VideoFrame(canvas, { timestamp: 0 });
const stream=await navigator.mediaDevices.getUserMedia({
audio: true,
video: {
width: { min: 1024, ideal: 1280, max: 1920 },
height: { min: 576, ideal: 720, max: 1080 }
}
});
// 获取媒体帧的配置:https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia
const track=stream.getTracks()[0];
const trackProcessor=new MediaStreamTrackProcessor(track);
const reader=trackProcessor.readable.getReader();
while (true) {
const result=await reader.read();
// 读取数据
if (result.done) break;
const frameFromCamera=result.value;
}
const pixelSize=4;
const init={
timestamp: 0,
codedWidth: 320,
codedHeight: 200,
format: "RGBA",
};
const data=new Uint8Array(init.codedWidth * init.codedHeight * pixelSize);
// 创建Uint8Array对象
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
}
}
const frame=new VideoFrame(data, init);
// 实例化VideoFrame对象
无论那种方式,都可以使用 VideoEncoder 将帧编码到 EncodedVideoChunk 对象中。在编码之前,需要给 VideoEncoder 两个 JavaScript 对象:
如果浏览器不支持配置,则 configure() 方法将抛出 NotSupportedError。 鼓励您使用配置调用静态方法
VideoEncoder.isConfigSupported() 以预先检查配置是否受支持并等待其promise的结果。
const init={
output: handleChunk,
// 处理快
error: (e)=> {
console.log(e.message);
},
// 处理错误
};
const config={
codec: "vp8",
width: 640,
height: 480,
bitrate: 2_000_000, // 2 Mbps
framerate: 30,
};
const { supported }=await VideoEncoder.isConfigSupported(config);
// 判断是否支持
if (supported) {
const encoder=new VideoEncoder(init);
encoder.configure(config);
} else {
// Try another config.
}
设置编码器后就可以通过 encode() 方法接受帧了。 configure() 和 encode() 都立即返回,无需等待实际工作完成。 它允许多个帧同时排队等待编码,而 encodeQueueSize 显示有多少请求在队列中等待先前的编码完成。
如果参数或方法调用顺序违反 API 约定,或者通过调用 error() 回调来解决编解码器实现中遇到的问题,可以通过立即抛出异常来报告错误。 如果编码成功完成,将使用新的编码块作为参数调用 output() 回调。
这里的另一个重要细节是,当不再需要框架时,需要通过调用 close() 来告知它们。
let frameCounter=0;
const track=stream.getVideoTracks()[0];
const trackProcessor=new MediaStreamTrackProcessor(track);
const reader=trackProcessor.readable.getReader();
while (true) {
const result=await reader.read();
if (result.done) break;
const frame=result.value;
if (encoder.encodeQueueSize > 2) {
// 太多帧要处理,编码器过载,丢弃当前帧
frame.close();
} else {
frameCounter++;
const keyframe=frameCounter % 150==0;
encoder.encode(frame, { keyFrame });
frame.close();
}
}
最后是通过编写一个函数来完成编码代码的时候了,该函数处理来自编码器的编码视频块。 通常此功能将通过网络发送数据块或将它们混合到媒体容器中进行存储。
function handleChunk(chunk, metadata) {
if (metadata.decoderConfig) {
// Decoder needs to be configured (or reconfigured) with new parameters
// when metadata has a new decoderConfig.
// Usually it happens in the beginning or when the encoder has a new
// codec specific binary configuration. (VideoDecoderConfig.description).
fetch("/upload_extra_data", {
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
body: metadata.decoderConfig.description,
});
}
// 真实编码数据块大小
const chunkData=new Uint8Array(chunk.byteLength);
chunk.copyTo(chunkData);
fetch(`/upload_chunk?timestamp=${chunk.timestamp}&type=${chunk.type}`, {
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
body: chunkData,
});
}
如果在某个时候您需要确保所有待处理的编码请求都已完成,可以调用 flush() 并等待它的promise结果。
await encoder.flush();
设置 VideoDecoder 与 VideoEncoder 类似:创建解码器时需要传递两个函数,并将编解码器参数提供给 configure()。
编解码器参数因编解码器而异。 例如,H.264 编解码器可能需要 AVCC 的二进制 blob,除非它以所谓的 Annex B 格式编码(encoderConfig.avc={ format: "annexb" })。
const init={
output: handleFrame,
error: (e)=> {
console.log(e.message);
},
};
const config={
codec: "vp8",
codedWidth: 640,
codedHeight: 480,
};
const { supported }=await VideoDecoder.isConfigSupported(config);
if (supported) {
const decoder=new VideoDecoder(init);
// 实例化编码器
decoder.configure(config);
// 配置编码器
} else {
// Try another config.
}
解码器初始化后,您可以开始为其提供 EncodedVideoChunk 对象。 要创建块,您需要:
此外,编码器发出的任何块都可以按原样为解码器准备好。 上面所说的关于错误报告和编码器方法的异步性质的所有内容对于解码器也同样适用。
const responses=await downloadVideoChunksFromServer(timestamp);
for (let i=0; i < responses.length; i++) {
const chunk=new EncodedVideoChunk({
timestamp: responses[i].timestamp,
type: responses[i].key ? "key" : "delta",
data: new Uint8Array(responses[i].body),
});
decoder.decode(chunk);
}
await decoder.flush();
现在是时候展示如何在页面上显示新解码的帧了。 最好确保解码器输出回调 (handleFrame()) 快速返回。 在下面的示例中,它仅将一个帧添加到准备渲染的帧队列中。 渲染是单独发生的,由两个步骤组成:
一旦不再需要某个帧,调用 close() 以在垃圾收集器到达它之前释放底层内存,这将减少 Web 应用程序使用的平均内存量。
const canvas=document.getElementById("canvas");
const ctx=canvas.getContext("2d");
let pendingFrames=[];
let underflow=true;
let baseTime=0;
function handleFrame(frame) {
pendingFrames.push(frame);
if (underflow) setTimeout(renderFrame, 0);
}
function calculateTimeUntilNextFrame(timestamp) {
if (baseTime==0) baseTime=performance.now();
let mediaTime=performance.now() - baseTime;
return Math.max(0, timestamp / 1000 - mediaTime);
}
async function renderFrame() {
underflow=pendingFrames.length==0;
if (underflow) return;
const frame=pendingFrames.shift();
// Based on the frame's timestamp calculate how much of real time waiting
// is needed before showing the next frame.
const timeUntilNextFrame=calculateTimeUntilNextFrame(frame.timestamp);
await new Promise((r)=> {
setTimeout(r, timeUntilNextFrame);
});
ctx.drawImage(frame, 0, 0);
frame.close();
// 立即启动下一帧的调用逻辑
setTimeout(renderFrame, 0);
}
https://developer.chrome.com/articles/webcodecs/(Chrome官方文档)
https://developer.mozilla.org/en-US/docs/Web/API/isSecureContext
https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts
https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream
https://www.w3.org/2020/06/machine-learning-workshop/talks/media_processing_hooks_for_the_web.html
将编码的视频流解码为原始视频数据,编码视频流可以来自网络流或文件,解码后即可渲染到屏幕.
正如我们所知,编码数据仅用于传输,无法直接渲染到屏幕上,所以这里利用FFmpeg解析文件中的编码的视频流,并将压缩视频数据(h264/h265)解码为指定格式(yuv,RGB)的视频原始数据,以渲染到屏幕上。 注意: 本例主要为解码,需要借助FFmpeg搭建模块,视频解析模块,渲染模块,这些模块在下面阅读前提皆有链接可直接访问。
FFmpeg parse流程
FFmpeg decode流程
- (void)viewDidLoad {
[super viewDidLoad];
[self setupUI];
}
- (void)setupUI {
self.previewView=[[XDXPreviewView alloc] initWithFrame:self.view.frame];
[self.view addSubview:self.previewView];
[self.view bringSubviewToFront:self.startBtn];
}
解析并解码文件中视频数据
- (void)startDecodeByFFmpegWithIsH265Data:(BOOL)isH265 {
NSString *path=[[NSBundle mainBundle] pathForResource:isH265 ? @"testh265" : @"testh264" ofType:@"MOV"];
XDXAVParseHandler *parseHandler=[[XDXAVParseHandler alloc] initWithPath:path];
XDXFFmpegVideoDecoder *decoder=[[XDXFFmpegVideoDecoder alloc] initWithFormatContext:[parseHandler getFormatContext] videoStreamIndex:[parseHandler getVideoStreamIndex]];
decoder.delegate=self;
[parseHandler startParseGetAVPackeWithCompletionHandler:^(BOOL isVideoFrame, BOOL isFinish, AVPacket packet) {
if (isFinish) {
[decoder stopDecoder];
return;
}
if (isVideoFrame) {
[decoder startDecodeVideoDataWithAVPacket:packet];
}
}];
}
将解码后数据渲染到屏幕上
-(void)getDecodeVideoDataByFFmpeg:(CMSampleBufferRef)sampleBuffer {
CVPixelBufferRef pix=CMSampleBufferGetImageBuffer(sampleBuffer);
[self.previewView displayPixelBuffer:pix];
}
C++音视频开发学习资料:点击领取→音视频开发(资料文档+视频教程+面试题)(FFmpeg+WebRTC+RTMP+RTSP+HLS+RTP)
1. 初始化实例对象
因为本例中的视频数据源是文件,而format context上下文实在parse模块初始化的,所以这里仅仅需要将其传入解码器即可.
- (instancetype)initWithFormatContext:(AVFormatContext *)formatContext videoStreamIndex:(int)videoStreamIndex {
if (self=[super init]) {
m_formatContext=formatContext;
m_videoStreamIndex=videoStreamIndex;
m_isFindIDR=NO;
m_base_time=0;
[self initDecoder];
}
return self;
}
2. 初始化解码器
- (void)initDecoder {
// 获取视频流
AVStream *videoStream=m_formatContext->streams[m_videoStreamIndex];
// 创建解码器上下文对象
m_videoCodecContext=[self createVideoEncderWithFormatContext:m_formatContext
stream:videoStream
videoStreamIndex:m_videoStreamIndex];
if (!m_videoCodecContext) {
log4cplus_error(kModuleName, "%s: create video codec failed",__func__);
return;
}
// 创建视频帧
m_videoFrame=av_frame_alloc();
if (!m_videoFrame) {
log4cplus_error(kModuleName, "%s: alloc video frame failed",__func__);
avcodec_close(m_videoCodecContext);
}
}
2.1. 创建解码器上下文对象
- (AVCodecContext *)createVideoEncderWithFormatContext:(AVFormatContext *)formatContext stream:(AVStream *)stream videoStreamIndex:(int)videoStreamIndex {
AVCodecContext *codecContext=NULL;
AVCodec *codec=NULL;
// 指定解码器名称, 这里使用苹果VideoToolbox中的硬件解码器
const char *codecName=av_hwdevice_get_type_name(AV_HWDEVICE_TYPE_VIDEOTOOLBOX);
// 将解码器名称转为对应的枚举类型
enum AVHWDeviceType type=av_hwdevice_find_type_by_name(codecName);
if (type !=AV_HWDEVICE_TYPE_VIDEOTOOLBOX) {
log4cplus_error(kModuleName, "%s: Not find hardware codec.",__func__);
return NULL;
}
// 根据解码器枚举类型找到解码器
int ret=av_find_best_stream(formatContext, AVMEDIA_TYPE_VIDEO, -1, -1, &codec, 0);
if (ret < 0) {
log4cplus_error(kModuleName, "av_find_best_stream faliture");
return NULL;
}
// 为解码器上下文对象分配内存
codecContext=avcodec_alloc_context3(codec);
if (!codecContext){
log4cplus_error(kModuleName, "avcodec_alloc_context3 faliture");
return NULL;
}
// 将视频流中的参数填充到视频解码器中
ret=avcodec_parameters_to_context(codecContext, formatContext->streams[videoStreamIndex]->codecpar);
if (ret < 0){
log4cplus_error(kModuleName, "avcodec_parameters_to_context faliture");
return NULL;
}
// 创建硬件解码器上下文
ret=InitHardwareDecoder(codecContext, type);
if (ret < 0){
log4cplus_error(kModuleName, "hw_decoder_init faliture");
return NULL;
}
// 初始化解码器上下文对象
ret=avcodec_open2(codecContext, codec, NULL);
if (ret < 0) {
log4cplus_error(kModuleName, "avcodec_open2 faliture");
return NULL;
}
return codecContext;
}
#pragma mark - C Function
AVBufferRef *hw_device_ctx=NULL;
static int InitHardwareDecoder(AVCodecContext *ctx, const enum AVHWDeviceType type) {
int err=av_hwdevice_ctx_create(&hw_device_ctx, type, NULL, NULL, 0);
if (err < 0) {
log4cplus_error("XDXParseParse", "Failed to create specified HW device.\n");
return err;
}
ctx->hw_device_ctx=av_buffer_ref(hw_device_ctx);
return err;
}
av_find_best_stream : 在文件中找到最佳流信息.
仅仅将解码器中具有相应字段的任何已分配字段par被释放并替换为par中相应字段的副本。不涉及解码器中没有par中对应项的字段。
int av_find_best_stream(AVFormatContext *ic,
enum FfmpegaVMediaType type,
int wanted_stream_nb,
int related_stream,
AVCodec **decoder_ret,
int flags);
2.2. 创建视频帧 AVFrame
作为解码后原始的音视频数据的容器.AVFrame通常被分配一次然后多次重复(例如,单个AVFrame以保持从解码器接收的帧)。在这种情况下,av_frame_unref()将释放框架所持有的任何引用,并在再次重用之前将其重置为其原始的清理状态。
// Get video frame
m_videoFrame=av_frame_alloc();
if (!m_videoFrame) {
log4cplus_error(kModuleName, "%s: alloc video frame failed",__func__);
avcodec_close(m_videoCodecContext);
}
首先找到编码数据流中第一个I帧, 然后调用avcodec_send_packet将压缩数据发送给解码器.最后利用循环接收avcodec_receive_frame解码后的视频数据.构造时间戳,并将解码后的数据填充到CVPixelBufferRef中并将其转为CMSampleBufferRef.
- (void)startDecodeVideoDataWithAVPacket:(AVPacket)packet {
if (packet.flags==1 && m_isFindIDR==NO) {
m_isFindIDR=YES;
m_base_time=m_videoFrame->pts;
}
if (m_isFindIDR==YES) {
[self startDecodeVideoDataWithAVPacket:packet
videoCodecContext:m_videoCodecContext
videoFrame:m_videoFrame
baseTime:m_base_time
videoStreamIndex:m_videoStreamIndex];
}
}
- (void)startDecodeVideoDataWithAVPacket:(AVPacket)packet videoCodecContext:(AVCodecContext *)videoCodecContext videoFrame:(AVFrame *)videoFrame baseTime:(int64_t)baseTime videoStreamIndex:(int)videoStreamIndex {
Float64 current_timestamp=[self getCurrentTimestamp];
AVStream *videoStream=m_formatContext->streams[videoStreamIndex];
int fps=DecodeGetAVStreamFPSTimeBase(videoStream);
avcodec_send_packet(videoCodecContext, &packet);
while (0==avcodec_receive_frame(videoCodecContext, videoFrame))
{
CVPixelBufferRef pixelBuffer=(CVPixelBufferRef)videoFrame->data[3];
CMTime presentationTimeStamp=kCMTimeInvalid;
int64_t originPTS=videoFrame->pts;
int64_t newPTS=originPTS - baseTime;
presentationTimeStamp=CMTimeMakeWithSeconds(current_timestamp + newPTS * av_q2d(videoStream->time_base) , fps);
CMSampleBufferRef sampleBufferRef=[self convertCVImageBufferRefToCMSampleBufferRef:(CVPixelBufferRef)pixelBuffer
withPresentationTimeStamp:presentationTimeStamp];
if (sampleBufferRef) {
if ([self.delegate respondsToSelector:@selector(getDecodeVideoDataByFFmpeg:)]) {
[self.delegate getDecodeVideoDataByFFmpeg:sampleBufferRef];
}
CFRelease(sampleBufferRef);
}
}
}
释放相关资源
因为公司项目需要,研究了下腾讯的实时音视频的C# Winform版本的Demo。
当然其他平台也有,不是我主要负责。
经过2天的摸索,对其代码和原理进行了一个简单的梳理。因为才接触腾讯的音视频直播,同时C# Winform相关的知识已经5年没碰了。
所以下面的内容,应该会出现一些偏差,仅供大家参考。
腾讯的Demo 下载地址:
其实整个项目的最为核心的文件是:TXLiteAVVideoViews。翻译过来的意思应该是腾讯轻量级的音视频视图。
在这个文件中,包含了3个非常重要的类,我认为音视频在C#`层面上的核心。
TXLiteAVVideoView
以下为我把非核心代码删除后的简单模型。
public class TXLiteAVVideoView: Panel {
// 帧缓存,可以理解为视频暂停时候的一个画面
private volatile FrameBufferInfo _mArgbFrame=new FrameBufferInfo();
public bool AppendVideoFrame(byte[] data, int width, int height, TRTCVideoPixelFormat videoFormat, TRTCVideoRotation rotation) {
//...
}
protected override void OnPaint(PaintEventArgs pe) {
//....
}
}
所以由此我们可以简单分析总结下:该类通过方法AppendVideoFrame接收TXLiteAVVideoViewManager传递过来的帧数据,在将帧数据保存到局部变量_mArgbFrame后调用refresh方法,该方法会调用重写后的OnPaint来画图。
TXLiteAVVideoViewManager
同样简化下代码:
class TXLiteAVVideoViewManager: ITRTCVideoRenderCallback {
private volatile Dictionary<string,TXLiteAVVideoView> _mMapViews;
public void onRenderVideoFrame(string userId, TRTCVideoStreamType streamType, TRTCVideoFrame frame) {
//...
}
}
我们可以简单看看onRenderVideoFrame的实现
public void onRenderVideoFrame(string userId, TRTCVideoStreamType streamType, TRTCVideoFrame frame) {
//....
TXLiteAVVideoView view=null;
lock(_mMapViews) {
view=_mMapViews[GetKey(userId, streamType)];
}
//调用 AppendVideoFrame 进行帧的渲染
view?.AppendVideoFrame(frame.data, (int) frame.width, (int) frame.height, frame.videoFormat, frame.rotation);
}
其本质也是从Dictionary中通过GetKey(userId, streamType)来构成key,获取到对应的view,然后进行AppendVideoFrame.
FrameBufferInfo
这个类的实现如下:
class FrameBufferInfo
{
public byte[] data { get; set; }
public int width { get; set; }
public int height { get; set; }
public bool newFrame { get; set; }
/**
* Rotation 是是否旋转
*/
public TRTCVideoRotation rotation { get; set; }
}
表示的应该是这个帧如何处理。
这是腾讯音视频实时通信的第一篇分析,后面会根据情况,看看有没有更多有意义的可以写文。
希望对大家有帮助。
如果本文对你有帮助,欢迎评论、点赞、转发、收藏、关注!
*请认真填写需求信息,我们会在24小时内与您取得联系。