WebRTC视频 03 - 视频采集类 VideoCaptureDS 上篇

WebRTC视频 01 - 视频采集整体架构
WebRTC视频 02 - 视频采集类 VideoCaptureModule

WebRTC视频 03 - 视频采集类 VideoCaptureDS 上篇\](本文) [WebRTC视频 04 - 视频采集类 VideoCaptureDS 中篇](https://blog.csdn.net/Ziwubiancheng/article/details/143749879) [WebRTC视频 05 - 视频采集类 VideoCaptureDS 下篇](https://blog.csdn.net/Ziwubiancheng/article/details/143752453?spm=1001.2014.3001.5502) ## 一、前言: 前面两篇文章我们介绍了WebRtc的视频采集架构,并且,分析了所有关键类之间如何相互协调,一直分析到操作VideoCaptureDS这个类为止。心中有了框架,接下来我们分析具体的点,就是VideoCaptureDS再往下如何操作硬件的。 ## 二、流程图: 其实主要干了几件事: ![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/858b63de1f244723b297b5ab6caccd92.png) * 连接CaptureFilter的输出Pin到SinkFilter的输入Pin,这样数据就源源不断从输出Pin到输入Pin了。 * CaptureFilter是由DirectShow提供的,可以通过CaptureFilter来控制DirectShow完成视频采集,而SinkFilter是webrtc自己构造的。 * 入口函数还记得吗?是VideoCaptureDS::Init()。 ## 三、COM编程方法介绍: #### CreateClassEnumerator: `CreateClassEnumerator`是DirectShow API中的一个函数,它用于创建一个枚举器对象,该对象可用于枚举系统中注册的所有DirectShow滤波器的类标识符(CLSID)。 该函数的原型通常是: ```cpp HRESULT CreateClassEnumerator( REFCLSID clsidDeviceClass, IEnumMoniker **ppEnumMoniker, DWORD dwFlags ); ``` * `clsidDeviceClass`: 指定要枚举的设备类别的 CLSID。传入 `NULL` 时,将枚举所有的设备类别。 * `ppEnumMoniker`: 指向 `IEnumMoniker` 接口指针的指针。枚举器将通过该指针返回。 * `dwFlags`: 可选的标志,用于指定枚举器的行为。 #### IEnumMoniker: `IEnumMoniker` 接口是COM编程中的一个接口,用于枚举 `IMoniker` 接口的集合。`IEnumMoniker` 接口中的 `Next` 方法用于检索指定数量的Moniker对象。 下面是 `IEnumMoniker` 接口的 `Next` 方法的一般原型: HRESULT Next( ULONG celt, IMoniker **rgelt, ULONG *pceltFetched ); * `celt`: 指定要检索的Moniker对象数量。 * `rgelt`: 用于输出枚举的Moniker对象的指针数组。 * `pceltFetched`: 指向一个 `ULONG` 变量的指针,用于返回实际成功检索的Moniker对象数量。 `Next` 方法会尝试从枚举器的当前位置检索指定数量的Moniker对象,并将它们填充到提供的 `rgelt` 数组中。成功获取的Moniker对象数量将通过 `pceltFetched` 参数返回。如果成功检索了指定数量的Moniker对象,则返回 `S_OK`,否则返回 `S_FALSE`。 #### BindToStorage: `IMoniker::BindToStorage` 是一个用于将 Moniker 绑定到存储对象的方法。在 COM 编程中,Moniker 是用于标识和定位对象的抽象机制,而 `BindToStorage` 允许将 Moniker 解析为存储对象,从而可以访问该对象的数据。 具体来说,`IMoniker::BindToStorage` 方法的作用是将 Moniker 绑定到存储器,并返回一个指向该存储器对象的接口指针,以便可以访问存储器中所包含的数据。这个方法通常用于从 Moniker 获取实际对象的数据或属性。 下面是 `IMoniker::BindToStorage` 方法的一般原型: HRESULT BindToStorage( IBindCtx *pbc, IMoniker *pmkToLeft, REFIID riid, void **ppvObj ); * `pbc`: 指向绑定上下文对象的指针,用于控制绑定操作的一些方面。 * `pmkToLeft`: 在某些情况下可能用到,表示左侧的 Moniker 对象。 * `riid`: 指定所请求接口的 IID(接口标识符)。 * `ppvObj`: 用于返回存储器对象的接口指针的指针。 通过调用 `IMoniker::BindToStorage` 方法,可以通过 Moniker 定位并访问存储器对象中的数据。这在 COM 编程中特别有用,特别是在处理对象链接和嵌入(OLE)等场景中。 #### IPropertyBag: `IPropertyBag` 是 COM 编程中的一个接口,用于提供一种机制,允许通过属性名称来检索和设置属性值。它通常用于在 COM 对象之间传递属性信息,并提供一种灵活的方式来访问和操作属性。 下面是 `IPropertyBag` 接口的一般原型: interface IPropertyBag : IUnknown { virtual HRESULT Read(LPCOLESTR pszPropName, VARIANT *pVar, IErrorLog *pErrorLog) = 0; virtual HRESULT Write(LPCOLESTR pszPropName, VARIANT *pVar) = 0; }; * `Read`: 通过属性名称读取属性值,并将其存储在传入的 `VARIANT` 结构中。如果属性不存在或读取失败,可以使用 `IErrorLog` 接口来记录错误信息。 * `Write`: 根据属性名称设置属性值,传入要设置的属性值的 `VARIANT` 结构。 通过 `IPropertyBag` 接口,可以实现一种通用的属性存储和检索机制,使得 COM 对象之间可以方便地传递和共享属性信息。这种机制在许多场景下非常有用,特别是在配置对象、持久化对象属性、或者在不同组件之间传递配置信息等方面。 #### BindToObject: `BindToObject` 是 COM 编程中常用的一个方法,通常用于将 Moniker 绑定到对象,从而获取对象的接口指针。这个方法通常由 `IMoniker` 接口提供,可以用于实现对象的定位和访问。 下面是 `IMoniker::BindToObject` 方法的一般原型: HRESULT BindToObject( IBindCtx *pbc, IMoniker *pmkToLeft, REFIID riidResult, void **ppvResult ); * `pbc`: 指向绑定上下文对象的指针,用于控制绑定操作的一些方面。 * `pmkToLeft`: 在某些情况下可能用到,表示左侧的 Moniker 对象。 * `riidResult`: 请求的接口的 IID(接口标识符)。 * `ppvResult`: 用于返回绑定到的对象的接口指针的指针。 通过调用 `IMoniker::BindToObject` 方法,可以将 Moniker 解析为一个对象,并获取该对象的接口指针。这个方法在 COM 编程中常用于实现对象的定位和访问,特别是在处理对象链接、远程过程调用(RPC)和其他需要动态定位对象的场景中。 ## 三、CaptureFilter: ### 1、作用: CaptureFilter就是控制DirectShow完成视频采集的。 ### 2、获取CaptureFilter: 代码入口: ```cpp int32_t VideoCaptureDS::Init(const char* deviceUniqueIdUTF8) { // ... // 构造CaptureFilter _captureFilter = _dsInfo.GetDeviceFilter(deviceUniqueIdUTF8); if (!_captureFilter) { RTC_LOG(LS_INFO) << "Failed to create capture filter."; return -1; } // ... } ``` 可以看出,是通过`DeviceInfoDS`的对象` _dsInfo` 来获取`CaptureFilter`的。 看看如何获取CaptureFilter的: ```cpp // 获取CaptureFilter走这儿,其中productUniqueIdUTF8和productUniqueIdUTF8Length都传入的0 IBaseFilter* DeviceInfoDS::GetDeviceFilter(const char* deviceUniqueIdUTF8, char* productUniqueIdUTF8, uint32_t productUniqueIdUTF8Length) { const int32_t deviceUniqueIdUTF8Length = (int32_t)strlen( (char*)deviceUniqueIdUTF8); // UTF8 is also NULL terminated if (deviceUniqueIdUTF8Length > kVideoCaptureUniqueNameLength) { RTC_LOG(LS_INFO) << "Device name too long"; return NULL; } // enumerate all video capture devices RELEASE_AND_CLEAR(_dsMonikerDevEnum); // CreateClassEnumerator 是获取视频采集设备的枚举器到_dsMonikerDevEnum HRESULT hr = _dsDevEnum->CreateClassEnumerator(CLSID_VideoInputDeviceCategory, &_dsMonikerDevEnum, 0); if (hr != NOERROR) { RTC_LOG(LS_INFO) << "Failed to enumerate CLSID_SystemDeviceEnum, error 0x" << rtc::ToHex(hr) << ". No webcam exist?"; return 0; } // reset之后就可以从0开始遍历了 _dsMonikerDevEnum->Reset(); ULONG cFetched; IMoniker* pM; IBaseFilter* captureFilter = NULL; bool deviceFound = false; // 使用Moniker遍历所有视频采集设备 while (S_OK == _dsMonikerDevEnum->Next(1, &pM, &cFetched) && !deviceFound) { IPropertyBag* pBag; // 获取对象的Bag接口,通过这个Bag接口后续获取属性 hr = pM->BindToStorage(0, 0, IID_IPropertyBag, (void**)&pBag); if (S_OK == hr) { // Find the description or friendly name. // 先找设备唯一标识,找不到就去找设备描述,再找不到就去找设备名 VARIANT varName; VariantInit(&varName); // 判断我们是否要获取设备唯一Id(UniqueId) if (deviceUniqueIdUTF8Length > 0) { hr = pBag->Read(L"DevicePath", &varName, 0); if (FAILED(hr)) { hr = pBag->Read(L"Description", &varName, 0); if (FAILED(hr)) { hr = pBag->Read(L"FriendlyName", &varName, 0); } } if (SUCCEEDED(hr)) { // 将设备路径进行 UTF-8 编码转换 char tempDevicePathUTF8[256]; // 临时存储 UTF-8 编码的设备路径 tempDevicePathUTF8[0] = 0; // 将获取的devicePath保存到tempDevicePathUTF8当中 WideCharToMultiByte(CP_UTF8, 0, varName.bstrVal, -1, tempDevicePathUTF8, sizeof(tempDevicePathUTF8), NULL, NULL); // 比较下是否为我们想要找的device if (strncmp(tempDevicePathUTF8, (const char*)deviceUniqueIdUTF8, deviceUniqueIdUTF8Length) == 0) { // We have found the requested device // 找到了请求的设备 deviceFound = true; // 获取CaptureFilter接口到captureFilter hr = pM->BindToObject(0, 0, IID_IBaseFilter, (void**)&captureFilter); if FAILED(hr) { RTC_LOG(LS_ERROR) << "Failed to bind to the selected " "capture device " << hr; } // 如果产品唯一标识存在且长度大于 0,获取设备名称,我们调用的时候传入的Null和0,这儿不会执行 if (productUniqueIdUTF8 && productUniqueIdUTF8Length > 0) // Get the device name { GetProductId(deviceUniqueIdUTF8, productUniqueIdUTF8, productUniqueIdUTF8Length); } } } } VariantClear(&varName); pBag->Release(); } pM->Release(); } return captureFilter; } ``` * 我们找到第一个就直接退出了,不会找出所有设备; * 我们调用的时候productUniqueIdUTF8使用的缺省值NULL,productUniqueIdUTF8Length使用的缺省值0,因此不会执行GetProductId; 至此,我们VideoCaptureDS就持有了CaptureFilter了。 ### 3、添加CaptureFilter到FilterGraph: 我们所有的Filter都必须添加到FilterGraph,这样,FilterGraph才能控制我们完成一些业务逻辑。 ```cpp int32_t VideoCaptureDS::Init(const char* deviceUniqueIdUTF8) { // 省略部分代码... // 构造CaptureFilter _captureFilter = _dsInfo.GetDeviceFilter(deviceUniqueIdUTF8); if (!_captureFilter) { RTC_LOG(LS_INFO) << "Failed to create capture filter."; return -1; } // Get the interface for DirectShow's GraphBuilder // 创建FilterGraph,并返回IGraphBuilder接口 HRESULT hr = CoCreateInstance(CLSID_FilterGraph, NULL, CLSCTX_INPROC_SERVER, IID_IGraphBuilder, (void**)&_graphBuilder); if (FAILED(hr)) { RTC_LOG(LS_INFO) << "Failed to create graph builder."; return -1; } // 获取IMediaControl接口,用于控制数据的流转 hr = _graphBuilder->QueryInterface(IID_IMediaControl, (void**)&_mediaControl); if (FAILED(hr)) { RTC_LOG(LS_INFO) << "Failed to create media control builder."; return -1; } // 将前面构造好的CaptureFilter添加到FilterGraph当中 hr = _graphBuilder->AddFilter(_captureFilter, CAPTURE_FILTER_NAME); if (FAILED(hr)) { RTC_LOG(LS_INFO) << "Failed to add the capture device to the graph."; return -1; } // 省略部分代码... } ``` ### 4、获取输出Pin: 前面我们枚举整个终端的视频采集设备,找到了我们请求的设备,并返回了CaptureFilter。我们CaptureFilter也有很多Pin,因此,如法炮制,继续枚举CaptureFilter的Pin,找到我们想要的输出Pin。 **入口函数:** ```cpp int32_t VideoCaptureDS::Init(const char* deviceUniqueIdUTF8) { // 省略部分代码... // 获取CaptureFilter的输出Pin _outputCapturePin = GetOutputPin(_captureFilter, PIN_CATEGORY_CAPTURE); if (!_outputCapturePin) { RTC_LOG(LS_INFO) << "Failed to get output capture pin"; return -1; } } ``` 注入我们要的输出Pin类型是PIN_CATEGORY_CAPTURE ```cpp /** * 获取输出引脚 * @param filter 表示是哪个Filter的引脚 * @param Category 表示引脚的种类 */ IPin* GetOutputPin(IBaseFilter* filter, REFGUID Category) { HRESULT hr; IPin* pin = NULL; IEnumPins* pPinEnum = NULL; // 获得枚举pin的接口到pPinEnum中 filter->EnumPins(&pPinEnum); if (pPinEnum == NULL) { return NULL; } // get first unconnected pin hr = pPinEnum->Reset(); // set to first pin 让从0开始枚举 // 遍历每个pin while (S_OK == pPinEnum->Next(1, &pin, NULL)) { // 获取这个pin的方向 PIN_DIRECTION pPinDir; pin->QueryDirection(&pPinDir); if (PINDIR_OUTPUT == pPinDir) // This is an output pin { // 判断pin的类型,是否为我们想要的 // GUID_NULL表示任意类型 if (Category == GUID_NULL || PinMatchesCategory(pin, Category)) { pPinEnum->Release(); return pin; } } pin->Release(); pin = NULL; } pPinEnum->Release(); return NULL; } ``` 其实逻辑也很简单了,就是遍历这个CaptureFilter的所有Pin,判断下是不是输出pin,如果是,再判断下pin类型是否为我们想要的,都符合就找到了。 那么,如何判断pin类型是否为我们想要的呢? ```cpp /** * 判断pin类型是否匹配 */ BOOL PinMatchesCategory(IPin* pPin, REFGUID Category) { BOOL bFound = FALSE; // 获取IKsPropertySet接口到pKs当中 IKsPropertySet* pKs = NULL; HRESULT hr = pPin->QueryInterface(IID_PPV_ARGS(&pKs)); if (SUCCEEDED(hr)) { GUID PinCategory; DWORD cbReturned; // 从AMPROPSETID_Pin这个属性集中,获取AMPROPERTY_PIN_CATEGORY属性, // 将属性数据存放于PinCategory当中,实际返回的数据大小存于cbReturned中 hr = pKs->Get(AMPROPSETID_Pin, AMPROPERTY_PIN_CATEGORY, NULL, 0, &PinCategory, sizeof(GUID), &cbReturned); // 判断返回的数据和我们要存储的数据大小是否一致,一致表示找到了目标pin if (SUCCEEDED(hr) && (cbReturned == sizeof(GUID))) { bFound = (PinCategory == Category); } pKs->Release(); } return bFound; } ``` 发现它是去获取我们请求的AMPROPERTY_PIN_CATEGORY这种类型的Pin的属性是否和我们请求的一直,一直就认为类型一致。 ## 四、SinkFilter: 前面已经创建好了输入数据的Filter,也就是CaptureFilter,并将它加入到了FilterGraph当中,同时找到了合适的输出Pin,准备输出数据,我们现在就创建一个输出Filter,也就是SinkFilter,接收CaptureFilter采集的数据。 注意:之前讲的CaptureFilter是由DirectShow提供的,而SinkFilter是webrtc自己构造的; **入口函数:** ```cpp int32_t VideoCaptureDS::Init(const char* deviceUniqueIdUTF8) { // Create the sink filte used for receiving Captured frames. // 开始构造CaptureSinkFilter sink_filter_ = new ComRefCount(this); // 将CaptureSinkFilter加入到GraphicBuilder当中 hr = _graphBuilder->AddFilter(sink_filter_, SINK_FILTER_NAME); if (FAILED(hr)) { RTC_LOG(LS_INFO) << "Failed to add the send filter to the graph."; return -1; } // 获取SinkFilter的输入pin _inputSendPin = GetInputPin(sink_filter_); if (!_inputSendPin) { RTC_LOG(LS_INFO) << "Failed to get input send pin"; return -1; } return 0; } ``` 发现我们是创建了一个CaptureSinkFilter对象,并让GraphicBuilder将自己管理起来,最后获取SinkFilter的输入Pin,既然SinkFilter是自己构建的,我们看看它的类长什么样: ### 1、CaptureSinkFilter: ```cpp class CaptureSinkFilter : public IBaseFilter { public: CaptureSinkFilter(VideoCaptureImpl* capture_observer); HRESULT SetRequestedCapability(const VideoCaptureCapability& capability); // Called on the capture thread. // Filter采集到数据之后,通过这个方法传给上层 void ProcessCapturedFrame(unsigned char* buffer, size_t length, const VideoCaptureCapability& frame_info); void NotifyEvent(long code, LONG_PTR param1, LONG_PTR param2); bool IsStopped() const; // IUnknown STDMETHOD(QueryInterface)(REFIID riid, void** ppv) override; // IPersist STDMETHOD(GetClassID)(CLSID* clsid) override; // IMediaFilter. STDMETHOD(GetState)(DWORD msecs, FILTER_STATE* state) override; STDMETHOD(SetSyncSource)(IReferenceClock* clock) override; STDMETHOD(GetSyncSource)(IReferenceClock** clock) override; STDMETHOD(Pause)() override; STDMETHOD(Run)(REFERENCE_TIME start) override; STDMETHOD(Stop)() override; // IBaseFilter STDMETHOD(EnumPins)(IEnumPins** pins) override; // 遍历所有引脚 STDMETHOD(FindPin)(LPCWSTR id, IPin** pin) override; STDMETHOD(QueryFilterInfo)(FILTER_INFO* info) override; STDMETHOD(JoinFilterGraph)(IFilterGraph* graph, LPCWSTR name) override; STDMETHOD(QueryVendorInfo)(LPWSTR* vendor_info) override; protected: virtual ~CaptureSinkFilter(); private: SequenceChecker main_checker_; const rtc::scoped_refptr> input_pin_; VideoCaptureImpl* const capture_observer_; FILTER_INFO info_ RTC_GUARDED_BY(main_checker_) = {}; // Set/cleared in JoinFilterGraph. The filter must be stopped (no capture) // at that time, so no lock is required. While the state is not stopped, // the sink will be used from the capture thread. IMediaEventSink* sink_ = nullptr; FILTER_STATE state_ RTC_GUARDED_BY(main_checker_) = State_Stopped; }; ``` * capture_observer_: 是一个观察者,sinkFilter获取到数据之后,通过这个observer传给上层; * state_: sinkFilter的状态,初始为stoped,运行之后就是started; * input_pin_:sinkFilter的输入pin,真正获取数据的地方; * ProcessCapturedFrame // Filter采集到数据之后,通过这个方法传给上层; * EnumPins // 遍历所有引脚 ### 2、输入Pin: ```cpp class CaptureInputPin : public IMemInputPin, public IPin { public: CaptureInputPin(CaptureSinkFilter* filter); HRESULT SetRequestedCapability(const VideoCaptureCapability& capability); // Notifications from the filter. void OnFilterActivated(); void OnFilterDeactivated(); protected: virtual ~CaptureInputPin(); private: CaptureSinkFilter* Filter() const; HRESULT AttemptConnection(IPin* receive_pin, const AM_MEDIA_TYPE* media_type); std::vector DetermineCandidateFormats( IPin* receive_pin, const AM_MEDIA_TYPE* media_type); void ClearAllocator(bool decommit); HRESULT CheckDirection(IPin* pin) const; // IUnknown STDMETHOD(QueryInterface)(REFIID riid, void** ppv) override; // clang-format off // clang isn't sure what to do with the longer STDMETHOD() function // declarations. // IPin // 用于连接某个pin STDMETHOD(Connect)(IPin* receive_pin, const AM_MEDIA_TYPE* media_type) override; // 当与某个pin连接成功之后,回调这个方法,查看能否与某个pin进行连接 STDMETHOD(ReceiveConnection)(IPin* connector, const AM_MEDIA_TYPE* media_type) override; STDMETHOD(Disconnect)() override; STDMETHOD(ConnectedTo)(IPin** pin) override; STDMETHOD(ConnectionMediaType)(AM_MEDIA_TYPE* media_type) override; STDMETHOD(QueryPinInfo)(PIN_INFO* info) override; STDMETHOD(QueryDirection)(PIN_DIRECTION* pin_dir) override; STDMETHOD(QueryId)(LPWSTR* id) override; STDMETHOD(QueryAccept)(const AM_MEDIA_TYPE* media_type) override; STDMETHOD(EnumMediaTypes)(IEnumMediaTypes** types) override; STDMETHOD(QueryInternalConnections)(IPin** pins, ULONG* count) override; STDMETHOD(EndOfStream)() override; STDMETHOD(BeginFlush)() override; STDMETHOD(EndFlush)() override; STDMETHOD(NewSegment)(REFERENCE_TIME start, REFERENCE_TIME stop, double rate) override; // IMemInputPin // 分配一个内存分配器(因为有些Filter是虚拟的,必须靠这个来 IMemInputPin 这些方法管理内存 STDMETHOD(GetAllocator)(IMemAllocator** allocator) override; STDMETHOD(NotifyAllocator)(IMemAllocator* allocator, BOOL read_only) override; STDMETHOD(GetAllocatorRequirements)(ALLOCATOR_PROPERTIES* props) override; // 获取当前引脚的数据(比如CaptureSinkFilter调用这个接口获取) STDMETHOD(Receive)(IMediaSample* sample) override; STDMETHOD(ReceiveMultiple)(IMediaSample** samples, long count, long* processed) override; STDMETHOD(ReceiveCanBlock)() override; // clang-format on SequenceChecker main_checker_; SequenceChecker capture_checker_; // 用户请求的能力 VideoCaptureCapability requested_capability_ RTC_GUARDED_BY(main_checker_); // Accessed on the main thread when Filter()->IsStopped() (capture thread not // running), otherwise accessed on the capture thread. // 最终最接近用户请求能力的真实能力 VideoCaptureCapability resulting_capability_; DWORD capture_thread_id_ = 0; // 内存分配器 rtc::scoped_refptr allocator_ RTC_GUARDED_BY(main_checker_); // 与当前pin连接的外部pin rtc::scoped_refptr receive_pin_ RTC_GUARDED_BY(main_checker_); std::atomic_bool flushing_{false}; std::atomic_bool runtime_error_{false}; // Holds a referenceless pointer to the owning filter, the name and // direction of the pin. The filter pointer can be considered const. // pin信息 PIN_INFO info_ = {}; // 每个pin都有自己支持的媒体类型,不支持的会拒绝掉 AM_MEDIA_TYPE media_type_ RTC_GUARDED_BY(main_checker_) = {}; }; ``` 我基本都写注释了,但是,还有几点需要注意: * IMemInputPin: 是与内存相关的,因为有些引脚是物理引脚,有些引脚是虚拟的,比如CaptureSinkFilter就是虚拟的Filter,虚拟的就会涉及到内存的申请释放,IMemInputPin就是定义这些方法的; * IPin就是实际的引脚; * 当调用CaptureInputPin的Receive获取CaptureInputPin的数据之后,就可以交给CaptureSinkFilter,再通过其ProcessCapturedFrame 传给capture_observer_; 至于InputPin的枚举获取和之前CaptureFilter的OutputPin逻辑一样,不再赘述。 ## 五、连接Filter: Filter连接是一个比较复杂的流程,打算单独写一篇介绍,读者可以先思考几个问题: 1. 每个Filter都有自己支持的能力,那么这俩Filter要连起来,分别应该选择哪个能力? 2. 两个Filter之间要传递数据怎么传递?存储数据的Buffer由哪个Filter管理? 3. 需要创建多大的Buffer,依据是什么?大了浪费空间,小了不够存。

相关推荐
唯独失去了从容5 小时前
WebRTC 源码原生端Demo入门-1
webrtc
charlie11451419111 小时前
编译日志:关于编译opencv带有ffmpeg视频解码支持的若干办法
opencv·ffmpeg·音视频·imx6ull·移植教程
批量小王子15 小时前
2025-05-10-FFmepg库裁切有水印的视频
音视频
Java搬砖组长16 小时前
小红书视频无水印下载方法
音视频
eguid_117 小时前
WebRTC流媒体传输协议RTP点到点传输协议介绍,WebRTC为什么使用RTP协议传输音视频流?
java·网络协议·音视频·webrtc·实时音视频
雾江流18 小时前
虚拟现实视频播放器 2.6.1 | 支持多种VR格式,提供沉浸式观看体验的媒体播放器
音视频·软件工程·vr
小虎卫远程打卡app19 小时前
视频编解码学习8之视频历史
学习·音视频·视频编解码
天夏已微凉19 小时前
1.3.2 linux音频PulseAudio详细介绍
linux·音视频
eguid_120 小时前
WebRTC工作原理详细介绍、WebRTC信令交互过程和WebRTC流媒体传输协议介绍
java·音视频·webrtc·实时音视频
追随远方1 天前
Android平台FFmpeg视频解码全流程指南
android·ffmpeg·音视频