基于AVStream框架的虚拟摄像头开发实现

本文详细介绍了如何基于AVStream框架开发虚拟摄像头,包括技术概览、描述符(设备、过滤器和引脚)的定义、分发表的作用、KSOBJECT_BAG管理和StreamPoint的使用,以及实际驱动和数据处理的实现步骤。
摘要由CSDN通过智能技术生成

基于AVStream框架的虚拟摄像头开发实现

在前面文章中,我们分析过虚拟摄像头有两种实现方式:

  1. 基于DirectShow框架的虚拟摄像头实现。
  2. 基于驱动的虚拟摄像头实现。

并且在前面文章中分析了基于DirectShow框架的方案(可以参见基于DirectShow框架的虚拟摄像头开发实现),在这里我们分析基于AVStream驱动框架的摄像头方案,该方案可以做到稳定性好,兼容几乎所有的应用软件。

1. AVStream技术概览

AVStream是用来取代Kernel Stream的音视频开发的一种新框架,其大致架构如下:
在这里插入图片描述

如上图我们可以发现其都是基于KS框架来扩展的,扩展基于Port Class的驱动框架(用来开发音频驱动),基于AVStream的驱动框架(用来开发视频驱动)。这个框架有两个比较明显的优势:

  1. 开发更加便捷和简单。
  2. 为音视频的处理提供了统一的内核流处理模型。

在AVStream框架中,都是基于对象来进行抽象和管理,整个对象形成一个类似树状的结构,框架如下:
在这里插入图片描述

如上图中所示:

  1. 对于一个AVStream驱动的设备,使用AVStream Device对象作为整个结构的树根。
  2. AVStream Device对象可以设置多个AVStream Filter Factory对象,用来创建过滤器。
  3. 对于一个过滤器可以可以包含多个AVStream Pin Factory。
  4. AVStream Pin Factory可以用来创建引脚实例。

AVStream的主要工作就是组织这些对象,让其协同工作。

2. 描述符

通过上面我们可以看到整个AVStream的核心工作原理是各自的对象来实现的,AVStream框架已经为我们做好了所有的处理流程(例如IRP数据的处理),我们只需要定义这些对象即可。在AVStream框架中,使用描述符来定义各自的对象,可以分为三种描述符:

  1. 设备描述符。
  2. 过滤器描述符。
  3. 引脚描述符。

设备描述符包括设备的信息,例如设备的PNP回调函数,以及设备中默认的过滤器描述符,其定义如下:

typedef struct _KSDEVICE_DESCRIPTOR {
  const KSDEVICE_DISPATCH   *Dispatch;
  ULONG                     FilterDescriptorsCount;
  const KSFILTER_DESCRIPTOR const * * FilterDescriptors;
  ULONG                     Version;
  ULONG                     Flags;
  PVOID                     Alignment;
} KSDEVICE_DESCRIPTOR, *PKSDEVICE_DESCRIPTOR;

KSFILTER_DESCRIPTOR表示过滤器描述符,描述了过滤器工厂的特性,框架使用该特性创建过滤器,该结构定义如下:

typedef struct _KSFILTER_DESCRIPTOR {
  const KSFILTER_DISPATCH     *Dispatch;
  const KSAUTOMATION_TABLE    *AutomationTable;
  ULONG                       Version;
  ULONG                       Flags;
  const GUID                  *ReferenceGuid;
  ULONG                       PinDescriptorsCount;
  ULONG                       PinDescriptorSize;
  const KSPIN_DESCRIPTOR_EX   *PinDescriptors;
  ULONG                       CategoriesCount;
  const GUID                  *Categories;
  ULONG                       NodeDescriptorsCount;
  ULONG                       NodeDescriptorSize;
  const KSNODE_DESCRIPTOR     *NodeDescriptors;
  ULONG                       ConnectionsCount;
  const KSTOPOLOGY_CONNECTION *Connections;
  const KSCOMPONENTID         *ComponentId;
} KSFILTER_DESCRIPTOR, *PKSFILTER_DESCRIPTOR;

KSPIN_DESCRIPTOR_EX表示引脚的描述符,引脚描述符定义了引脚工厂的特性,框架使用这些特性来创建引脚实例,该结构定义如下:

typedef struct _KSPIN_DESCRIPTOR_EX {
  const KSPIN_DISPATCH         *Dispatch;
  const KSAUTOMATION_TABLE     *AutomationTable;
  KSPIN_DESCRIPTOR             PinDescriptor;
  ULONG                        Flags;
  ULONG                        InstancesPossible;
  ULONG                        InstancesNecessary;
  const KSALLOCATOR_FRAMING_EX *AllocatorFraming;
  PFNKSINTERSECTHANDLEREX      IntersectHandler;
} KSPIN_DESCRIPTOR_EX, *PKSPIN_DESCRIPTOR_EX;

通过这几个描述符,我们就定义了整个驱动的对象属性信息,AVStream框架就可以通过这些属性创建各自的设备对象,过滤器工厂,过滤器实例,引脚工厂,引脚实例等结构。

3. 分发表

在AVStrem架构中框架已经替我们做好了大部分的事情了,但是我们怎么和框架进行交互呢?这个就是分发表的作用,在每个描述符中都有一个KSXXX_DISPATCH定义着各种回调函数。

KSDEVICE_DISPATCH表示设备创建和PNP处理相关的回调接口,定义如下:

typedef struct _KSDEVICE_DISPATCH {
  PFNKSDEVICECREATE            Add;
  PFNKSDEVICEPNPSTART          Start;
  PFNKSDEVICE                  PostStart;
  PFNKSDEVICEIRP               QueryStop;
  PFNKSDEVICEIRPVOID           CancelStop;
  PFNKSDEVICEIRPVOID           Stop;
  PFNKSDEVICEIRP               QueryRemove;
  PFNKSDEVICEIRPVOID           CancelRemove;
  PFNKSDEVICEIRPVOID           Remove;
  PFNKSDEVICEQUERYCAPABILITIES QueryCapabilities;
  PFNKSDEVICEIRPVOID           SurpriseRemoval;
  PFNKSDEVICEQUERYPOWER        QueryPower;
  PFNKSDEVICESETPOWER          SetPower;
  PFNKSDEVICEIRP               QueryInterface;
} KSDEVICE_DISPATCH, *PKSDEVICE_DISPATCH;

KSFILTER_DISPATCHKSPIN_DISPATCH分别表示过滤器和引脚相关的回调函数,定义如下:

typedef struct _KSFILTER_DISPATCH {
  PFNKSFILTERIRP     Create;
  PFNKSFILTERIRP     Close;
  PFNKSFILTERPROCESS Process;
  PFNKSFILTERVOID    Reset;
} KSFILTER_DISPATCH, *PKSFILTER_DISPATCH;

typedef struct _KSPIN_DISPATCH {
  PFNKSPINIRP                Create;
  PFNKSPINIRP                Close;
  PFNKSPIN                   Process;
  PFNKSPINVOID               Reset;
  PFNKSPINSETDATAFORMAT      SetDataFormat;
  PFNKSPINSETDEVICESTATE     SetDeviceState;
  PFNKSPIN                   Connect;
  PFNKSPINVOID               Disconnect;
  const KSCLOCK_DISPATCH     *Clock;
  const KSALLOCATOR_DISPATCH *Allocator;
} KSPIN_DISPATCH, *PKSPIN_DISPATCH;

通过上面KSFILTER_DISPATCHKSPIN_DISPATCH的定义,我们可以发现两个分发表中都有一个Process处理函数,那么具体处理数据的是哪一个呢?这里就涉及到数据的两种处理模式:

  1. 基于过滤器的处理模式。
  2. 基于引脚的处理模式。

在我们的实际情况中,我们很少使用基于过滤器的处理模式,都是在各自的引脚中处理其数据。

4. KSOBJECT_BAG

KSOBJECT_BAG是用来管理对象的一个容器,在上述的KSDEVICEKSFILTERFACTORYKSFILTERKSPIN结构中都存在KSOBJECT_BAG,我们以其中一个为例,如下:

typedef struct _KSDEVICE {
  const KSDEVICE_DESCRIPTOR *Descriptor;
  KSOBJECT_BAG              Bag;
  PVOID                     Context;
  PDEVICE_OBJECT            FunctionalDeviceObject;
  PDEVICE_OBJECT            PhysicalDeviceObject;
  PDEVICE_OBJECT            NextDeviceObject;
  BOOLEAN                   Started;
  SYSTEM_POWER_STATE        SystemPowerState;
  DEVICE_POWER_STATE        DevicePowerState;
} KSDEVICE, *PKSDEVICE;

我们使用KsAddItemToObjectBag将对象添加到KSOBJECT_BAG,并且指定释放函数,当KSOBJECT_BAG被删除的时候,释放函数就会自动调用,该函数声明如下:

KSDDKAPI NTSTATUS KsAddItemToObjectBag(
  KSOBJECT_BAG           ObjectBag,
  __drv_aliasesMem PVOID Item,
  PFNKSFREE              Free
);

一般来说我们可以使用如下:

Status = KsAddItemToObjectBag(Pin->Bag, 
    reinterpret_cast <PVOID> (CapPin),
    reinterpret_cast <PFNKSFREE>(CCapturePin::Cleanup));

5. StreamPoint

当我们用户层发起读取(写入)的IRP请求之后,AVStream就会将IRP分解成多个数据帧,然后放入队列,并且使用流指针来指向数据帧的抽象,整个框架如下:
在这里插入图片描述

如上图,整个队列中存在一个头和尾的指针,分别使用如下函数获取:

KSDDKAPI PKSSTREAM_POINTER KsPinGetLeadingEdgeStreamPointer(
  [in] PKSPIN                 Pin,
  [in] KSSTREAM_POINTER_STATE State
);

KSDDKAPI PKSSTREAM_POINTER KsPinGetTrailingEdgeStreamPointer(
  [in] PKSPIN                 Pin,
  [in] KSSTREAM_POINTER_STATE State
);

KSSTREAM_POINTER描述着数据帧的情况如下:

typedef struct _KSSTREAM_POINTER {
  PVOID                    Context;
  PKSPIN                   Pin;
  PKSSTREAM_HEADER         StreamHeader;
  PKSSTREAM_POINTER_OFFSET Offset;
  KSSTREAM_POINTER_OFFSET  OffsetIn;
  KSSTREAM_POINTER_OFFSET  OffsetOut;
} KSSTREAM_POINTER, *PKSSTREAM_POINTER;

typedef struct {
  ULONG    Size;
  ULONG    TypeSpecificFlags;
  KSTIME   PresentationTime;
  LONGLONG Duration;
  ULONG    FrameExtent;
  ULONG    DataUsed;
  PVOID    Data;
  ULONG    OptionsFlags;
  ULONG    Reserved;
} KSSTREAM_HEADER, *PKSSTREAM_HEADER;

其中KSSTREAM_HEADER表示数据帧,里面有数据的地址和其他信息。对于KSSTREAM_POINTER指向的数据,我们可以分多次处理,如果我们处理了部分数据,那么就需要对KSSTREAM_POINTER_OFFSET进行处理,如下:

KSDDKAPI NTSTATUS KsStreamPointerAdvanceOffsets(
  [in] PKSSTREAM_POINTER StreamPointer,
  [in] ULONG             InUsed,
  [in] ULONG             OutUsed,
  [in] BOOLEAN           Eject
);

6. 实现

虚拟摄像头的开发大致可以分为三个部分:

  1. 整个驱动框架的处理。
  2. 各个描述符的定义。
  3. 数据的模拟处理。

下面分别看一下这三个模块的实现过程。

6.1 框架实现

基于AVStream框架实现的虚拟摄像头实现特别简单,使用KsInitializeDriver初始化驱动即可,如下:

extern "C"
NTSTATUS
DriverEntry(
	IN PDRIVER_OBJECT DriverObject,
	IN PUNICODE_STRING RegistryPath
)
{
	return KsInitializeDriver(DriverObject, RegistryPath, &CaptureDeviceDescriptor);
}

这里我们只需要提供好设备描述符信息即可,我们定义设备描述符如下:

const
KSDEVICE_DESCRIPTOR
CaptureDeviceDescriptor = 
{
	&CaptureDeviceDispatch,
	0,
	NULL
};

6.2 描述符的实现

我们在设备启动IRP_MN_START_DEVICE的回调函数CCaptureDevice::PnpStart中,动态创建过滤器工厂,代码如下:

Status = KsCreateFilterFactory(m_Device->FunctionalDeviceObject,
	&CaptureFilterDescriptor,
	L"GLOBAL",
	NULL,
	KSCREATE_ITEM_FREEONSTOP,
	NULL,
	NULL,
	NULL);

这里我们只需要提供过滤器工厂的描述符,框架就会完成其他工作了,描述定义如下:

const
KSFILTER_DESCRIPTOR
CaptureFilterDescriptor = 
{
	&CaptureFilterDispatch,                 // Dispatch Table
	NULL,                                   // Automation Table
	KSFILTER_DESCRIPTOR_VERSION,            // Version
	0,                                      // Flags
	&KSNAME_Filter,                         // Reference GUID
	DEFINE_KSFILTER_PIN_DESCRIPTORS(CaptureFilterPinDescriptors),
	DEFINE_KSFILTER_CATEGORIES(CaptureFilterCategories),
	0,
	sizeof(KSNODE_DESCRIPTOR),
	NULL,
	0,
	NULL,
	NULL                                    // Component ID
};

6.3 数据处理

这里我们分析基于引脚的处理模型,在引脚中提供了分发函数如下:

const
KSPIN_DISPATCH
CapturePinDispatch = {
	CCapturePin::DispatchCreate,            // Pin Create
	NULL,                                   // Pin Close
	CCapturePin::DispatchProcess,           // Pin Process
	NULL,                                   // Pin Reset
	CCapturePin::DispatchSetFormat,         // Pin Set Data Format
	CCapturePin::DispatchSetState,          // Pin Set Device State
	NULL,                                   // Pin Connect
	NULL,                                   // Pin Disconnect
	NULL,                                   // Clock Dispatch
	NULL                                    // Allocator Dispatch
};

只需要在CCapturePin::DispatchProcess对数据帧进行图像数据提供即可,如下:

NTSTATUS
CCapturePin::Process(
)
{

	PAGED_CODE();

	NTSTATUS Status = STATUS_SUCCESS;
	PKSSTREAM_POINTER Leading;
	Leading = KsPinGetLeadingEdgeStreamPointer(m_Pin, KSSTREAM_POINTER_STATE_LOCKED);

	while (NT_SUCCESS(Status) && Leading) 
	{

		PKSSTREAM_POINTER ClonePointer;
		PSTREAM_POINTER_CONTEXT SPContext = NULL;

		if (NULL == Leading->StreamHeader->Data) 
		{
			Status = KsStreamPointerAdvance(Leading);
			continue;
		}

		if (!m_PreviousStreamPointer)
		{
			Status = KsStreamPointerClone(Leading,
				NULL,
				sizeof(STREAM_POINTER_CONTEXT),
				&ClonePointer);
			if (NT_SUCCESS(Status))
			{
				ClonePointer->StreamHeader->DataUsed = 0;
				SPContext = reinterpret_cast<PSTREAM_POINTER_CONTEXT>(ClonePointer->Context);
				SPContext->BufferVirtual = reinterpret_cast <PUCHAR>(ClonePointer->StreamHeader->Data);
			}
		}
		else 
		{
			ClonePointer = m_PreviousStreamPointer;
			SPContext = reinterpret_cast<PSTREAM_POINTER_CONTEXT>(ClonePointer->Context);
			Status = STATUS_SUCCESS;
		}

		if (!NT_SUCCESS(Status)) 
		{
			KsStreamPointerUnlock(Leading, FALSE);
			break;
		}
		ULONG MappingsUsed = m_Device->ProgramScatterGatherMappings(ClonePointer,
				&(SPContext->BufferVirtual),
				Leading->OffsetOut.Mappings,  
				Leading->OffsetOut.Remaining);  
		if (MappingsUsed == Leading->OffsetOut.Remaining) 
		{
			m_PreviousStreamPointer = NULL;
		}
		else 
		{
			m_PreviousStreamPointer = ClonePointer;
		}

		if (MappingsUsed) 
		{
			Status = KsStreamPointerAdvanceOffsets(Leading,
					0,
					MappingsUsed,
					FALSE);
		}
		else 
		{
			Status = STATUS_PENDING;
			break;
		}
	}

	if (!Leading) 
	{
		m_PendIo = TRUE;
		Status = STATUS_PENDING;
	}

	if (NT_SUCCESS(Status) && Leading)
	{
		KsStreamPointerUnlock(Leading, FALSE);
	}
	else 
	{
		if (Status == STATUS_DEVICE_NOT_READY)
		{
			Status = STATUS_SUCCESS;
		}
	}

	if (!NT_SUCCESS(Status) || Status == STATUS_PENDING)
	{
		m_PendIo = TRUE;
	}

	return Status;
}

7. 总结

在数据处理函数CCapturePin::Process中,我们提供自己的图像数据,就可以完成定制化的虚拟摄像头了,虚拟摄像头如下:
在这里插入图片描述

可以使用GraphEdit来渲染虚拟摄像头的数据,如下:
在这里插入图片描述

  • 30
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
要使用Qt和FFmpeg实现网络摄像头视频录制,您需要做以下几个步骤: 1. 安装FFmpeg库。您可以从官方网站下载预编译的二进制文件,或者自己编译源代码。 2. 在Qt项目中导入FFmpeg库。您可以使用Qt的插件系统或手动链接库文件。 3. 创建一个Qt界面,用于显示摄像头捕捉到的视频。 4. 使用FFmpeg库编写代码,将摄像头捕捉到的视频转换为所需的格式并保存到本地文件。 以下是一个简单的示例代码,演示如何使用Qt和FFmpeg实现网络摄像头视频录制: ```cpp #include <QApplication> #include <QMainWindow> #include <QVBoxLayout> #include <QLabel> #include <QTimer> #include <QDebug> #include <QDateTime> #include <QThread> extern "C" { #include "libavcodec/avcodec.h" #include "libavformat/avformat.h" #include "libswscale/swscale.h" } #define WIDTH 640 #define HEIGHT 480 #define FPS 25 class CameraWidget : public QWidget { public: CameraWidget(QWidget *parent = nullptr) : QWidget(parent) { label_ = new QLabel(this); QVBoxLayout *layout = new QVBoxLayout(this); layout->addWidget(label_); setLayout(layout); } virtual ~CameraWidget() {} void start() { timer_ = new QTimer(this); connect(timer_, SIGNAL(timeout()), this, SLOT(update())); timer_->start(1000/FPS); } void stop() { timer_->stop(); delete timer_; timer_ = nullptr; } protected: void update() { // Capture camera frame QImage image(WIDTH, HEIGHT, QImage::Format_RGB888); for (int y = 0; y < HEIGHT; y++) { for (int x = 0; x < WIDTH; x++) { QRgb color = qRgb(qrand() % 256, qrand() % 256, qrand() % 256); image.setPixel(x, y, color); } } // Display camera frame label_->setPixmap(QPixmap::fromImage(image)); // Save camera frame to file if (recording_) { if (!formatContext_) { initFormatContext(); } if (formatContext_ && videoStream_) { AVPacket packet; av_init_packet(&packet); packet.data = nullptr; packet.size = 0; if (av_new_packet(&packet, WIDTH * HEIGHT * 3) < 0) { qWarning() << "Failed to allocate packet"; } SwsContext *swsContext = sws_getContext(WIDTH, HEIGHT, AV_PIX_FMT_RGB24, WIDTH, HEIGHT, videoStream_->codecpar->format, SWS_BILINEAR, nullptr, nullptr, nullptr); if (swsContext) { uint8_t *srcData[4] = {(uint8_t *)image.bits(), nullptr, nullptr, nullptr}; int srcLinesize[4] = {image.bytesPerLine(), 0, 0, 0}; uint8_t *dstData[4] = {nullptr, nullptr, nullptr, nullptr}; int dstLinesize[4] = {0, 0, 0, 0}; av_image_alloc(dstData, dstLinesize, videoStream_->codecpar->width, videoStream_->codecpar->height, videoStream_->codecpar->format, 1); if (dstData[0] && av_image_fill_arrays(videoFrame_->data, videoFrame_->linesize, dstData[0], videoStream_->codecpar->format, videoStream_->codecpar->width, videoStream_->codecpar->height, 1) >= 0) { sws_scale(swsContext, srcData, srcLinesize, 0, HEIGHT, videoFrame_->data, videoFrame_->linesize); videoFrame_->pts = pts_++; if (avcodec_send_frame(videoCodecContext_, videoFrame_) == 0) { while (avcodec_receive_packet(videoCodecContext_, &packet) == 0) { packet.stream_index = videoStream_->index; packet.pts = av_rescale_q(packet.pts, videoCodecContext_->time_base, videoStream_->time_base); packet.dts = av_rescale_q(packet.dts, videoCodecContext_->time_base, videoStream_->time_base); packet.duration = av_rescale_q(packet.duration, videoCodecContext_->time_base, videoStream_->time_base); packet.pos = -1; if (av_interleaved_write_frame(formatContext_, &packet) < 0) { qWarning() << "Failed to write packet"; } av_packet_unref(&packet); } } } av_freep(&dstData[0]); sws_freeContext(swsContext); } av_packet_unref(&packet); } } } void initFormatContext() { QString filename = QDateTime::currentDateTime().toString("yyyy-MM-dd_hh-mm-ss"); filename = QDir::tempPath() + "/" + filename + ".avi"; if (avformat_alloc_output_context2(&formatContext_, nullptr, "avi", filename.toUtf8().constData()) < 0) { qWarning() << "Failed to allocate output format context"; return; } if (avio_open(&formatContext_->pb, filename.toUtf8().constData(), AVIO_FLAG_WRITE) < 0) { qWarning() << "Failed to open output file"; avformat_free_context(formatContext_); formatContext_ = nullptr; return; } videoStream_ = avformat_new_stream(formatContext_, nullptr); if (!videoStream_) { qWarning() << "Failed to create video stream"; avformat_free_context(formatContext_); formatContext_ = nullptr; return; } videoCodecContext_ = avcodec_alloc_context3(nullptr); if (!videoCodecContext_) { qWarning() << "Failed to allocate codec context"; avformat_free_context(formatContext_); formatContext_ = nullptr; return; } videoCodecContext_->codec_id = AV_CODEC_ID_H264; videoCodecContext_->codec_type = AVMEDIA_TYPE_VIDEO; videoCodecContext_->width = WIDTH; videoCodecContext_->height = HEIGHT; videoCodecContext_->pix_fmt = AV_PIX_FMT_YUV420P; videoCodecContext_->time_base = av_make_q(1, FPS); videoStream_->codecpar->codec_id = AV_CODEC_ID_H264; videoStream_->codecpar->codec_type = AVMEDIA_TYPE_VIDEO; videoStream_->codecpar->width = WIDTH; videoStream_->codecpar->height = HEIGHT; videoStream_->codecpar->format = videoCodecContext_->pix_fmt; videoStream_->time_base = av_make_q(1, FPS); if (avcodec_open2(videoCodecContext_, avcodec_find_encoder(videoCodecContext_->codec_id), nullptr) < 0) { qWarning() << "Failed to open codec"; avformat_free_context(formatContext_); formatContext_ = nullptr; return; } if (avformat_write_header(formatContext_, nullptr) < 0) { qWarning() << "Failed to write header"; avformat_free_context(formatContext_); formatContext_ = nullptr; return; } videoFrame_ = av_frame_alloc(); if (!videoFrame_) { qWarning() << "Failed to allocate frame"; avformat_free_context(formatContext_); formatContext_ = nullptr; return; } videoFrame_->format = videoCodecContext_->pix_fmt; videoFrame_->width = WIDTH; videoFrame_->height = HEIGHT; if (av_frame_get_buffer(videoFrame_, 32) < 0) { qWarning() << "Failed to allocate frame buffer"; avformat_free_context(formatContext_); formatContext_ = nullptr; return; } pts_ = 0; } private: QLabel *label_; QTimer *timer_ = nullptr; bool recording_ = false; AVFormatContext *formatContext_ = nullptr; AVStream *videoStream_ = nullptr; AVCodecContext *videoCodecContext_ = nullptr; AVFrame *videoFrame_ = nullptr; int64_t pts_ = 0; }; int main(int argc, char *argv[]) { QApplication a(argc, argv); QMainWindow mainWindow; CameraWidget cameraWidget(&mainWindow); mainWindow.setCentralWidget(&cameraWidget); cameraWidget.start(); QThread::sleep(10); cameraWidget.stop(); return a.exec(); } ``` 该示例代码使用Qt中的QTimer类定期捕获摄像头的视频,并将其显示在界面上。如果设置recording_为true,则使用FFmpeg库将当前帧转换为指定格式并写入文件。请注意,此示例代码仅是演示如何使用Qt和FFmpeg实现网络摄像头视频录制的示例,您需要根据您的实际需求进行修改和调整。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值