三、视频设备的枚举以及插拔检测
一、前言
本章主要讲述,如何获取设备名称以及guid,采集设备的采集格式识别,设备的插拔
设备列表以及属性的获取使用的directshow(后续的MediaFoundation无法获取OBS摄像头)
设备的插拔使用的是QT 捕获系统消息,捕获到设备插拔后,重新获取下设备列表(这里并没有动态的添加或者删除,考虑的主要是维护UI显示时 设备顺序的一致性)
二、设备列表的获取
ICreateDevEnum 接口,创建特定的类(如视频捕获设备,音频捕获设备,视频压缩等)的一个枚举器 ,可以使用CLSID_SystemDeviceEnum来得到该指针。
CreateDevEnum::CreateClassEnumerator(
REFCLSID clsidDeviceClass, //设备类别
IEnumMoniker **ppEnumMoniker, //输出参数,IEnumMoniker ××
DWORD dwFlags
);
IEnumMoniker 接口, 表示特定的设备枚举类
IMoniker::Enum 方法获取指向 IEnumMoniker 实现的指针,该实现可以通过名字对象的组件向前或向后枚举。
IRunningObjectTable::EnumRunning 方法返回一个指向 IEnumMoniker 实现的指针,该实现可以枚举在运行对象表中注册的名字对象。
IEnumMoniker::Next 此方法检索枚举序列中下一个设备是否存在
struct CameraDevice
{int nIndex; // indexstd::string uid; // 硬件层uniqueId mac中为BuiltInMicrophoneDevice std::string name; // 设备名称
};std::map<std::string, CameraDevice> VideoCoreDevice::getVideoDeviceList()
{if(!m_pDevEnum){::CoCreateInstance(CLSID_SystemDeviceEnum, NULL, CLSCTX_INPROC, IID_ICreateDevEnum, (void**)&m_pDevEnum);}std::map<std::string, CameraDevice> devices;if (!m_bCoUninitializeIsRequired){goto END;}HRESULT hr = m_pDevEnum->CreateClassEnumerator(CLSID_VideoInputDeviceCategory, &m_pMonikerDevEnum, 0);if (hr != NOERROR){std::cout << "CreateClassEnumerator failed" << std::endl;goto END;}m_pMonikerDevEnum->Reset();ULONG cFetched;IMoniker* pM;int index = 0;while (S_OK == m_pMonikerDevEnum->Next(1, &pM, &cFetched)) {IPropertyBag* pBag;hr = pM->BindToStorage(0, 0, IID_IPropertyBag, (void**)&pBag);if (S_OK == hr) {// Find the description or friendly name.VARIANT varName;VariantInit(&varName);hr = pBag->Read(L"Description", &varName, 0);if (FAILED(hr)) {hr = pBag->Read(L"FriendlyName", &varName, 0);}if (SUCCEEDED(hr)) {// ignore all VFW driversif ((wcsstr(varName.bstrVal, (L"(VFW)")) == NULL) &&(_wcsnicmp(varName.bstrVal, (L"Google Camera Adapter"), 21) != 0)) {// Found a valid device.{char device_name[256] = { 0 };char unique_name[256] = { 0 };char product_name[256] = { 0 };int convResult = WideCharToMultiByte(CP_UTF8, 0, varName.bstrVal, -1, (char*)device_name, sizeof(device_name), NULL, NULL);if (convResult == 0){std::cout << "WideCharToMultiByte failed" << std::endl;goto END;}hr = pBag->Read(L"DevicePath", &varName, 0);if (FAILED(hr)){strncpy_s((char*)unique_name, sizeof(unique_name),(char*)device_name, convResult);}else{convResult = WideCharToMultiByte(CP_UTF8, 0, varName.bstrVal, -1, (char*)unique_name, sizeof(unique_name), NULL, NULL);if (convResult == 0){std::cout << "WideCharToMultiByte failed" << std::endl;goto END;}}GetProductId(unique_name, product_name, sizeof(product_name));CameraDevice camera;camera.nIndex = index;camera.name = device_name;camera.uid = unique_name;devices.insert(std::make_pair(camera.uid, camera));}++index; // increase the number of valid devices}}VariantClear(&varName);pBag->Release();pM->Release();}}END:return devices;
}// 不同获取方式得到的ID不一致,通过处理得到相同的ID
void GetProductId(const char* devicePath, char* productUniqueIdUTF8, uint32_t productUniqueIdUTF8Length)
{*productUniqueIdUTF8 = '\0';char* startPos = strstr((char*)devicePath, "\\\\?\\");if (!startPos) {strncpy_s((char*)productUniqueIdUTF8, productUniqueIdUTF8Length, "", 1);std::cout << "Failed to get the product Id" << std::endl;return;}startPos += 4;char* pos = strchr(startPos, '&');if (!pos || pos >= (char*)devicePath + strlen((char*)devicePath)) {strncpy_s((char*)productUniqueIdUTF8, productUniqueIdUTF8Length, "", 1);std::cout << "Failed to get the product Id" << std::endl;return;}// Find the second occurrence.pos = strchr(pos + 1, '&');uint32_t bytesToCopy = (uint32_t)(pos - startPos);if (pos && (bytesToCopy < productUniqueIdUTF8Length) &&bytesToCopy <= kVideoCaptureProductIdLength) {strncpy_s((char*)productUniqueIdUTF8, productUniqueIdUTF8Length,(char*)startPos, bytesToCopy);}else{strncpy_s((char*)productUniqueIdUTF8, productUniqueIdUTF8Length, "", 1);std::cout << "Failed to get the product Id" << std::endl;}
}
三、设备的插拔检测
目前使用的是Qt::nativeEventFilter 过滤设备插拔信息,然后去响应设备列表
// 头文件
#pragma once
#include <QWidget>
#include <QUuid>
#include <QAbstractNativeEventFilter>
#include <Windows.h>
#include <QHash>class VideoNotificationClient : public QAbstractNativeEventFilter, public QWidget
{
public:class Listener{public:virtual void onDeviceAdded(const std::string& uid) = 0;virtual void onDeviceRemoved(const std::string& uid) = 0;};public:void initialized();void uninstallFilter();bool nativeEventFilter(const QByteArray& eventType, void* message, long* result) override;public:VideoNotificationClient(VideoNotificationClient::Listener* listener);~VideoNotificationClient();private:bool m_bInitialized;QHash<QUuid, HDEVNOTIFY> m_hSevNotifys;VideoNotificationClient::Listener* m_pListener;
};
//源文件
#include "VideoNotificationClient.h"
#include <QDebug>
#include <iostream>
#include <Windows.h>
#include <Dbt.h>
#include <devguid.h>
//具体的设备guid如usbiodef需要initguid
#include <initguid.h>
//USB设备
//GUID_DEVINTERFACE_USB_DEVICE
#include <usbiodef.h>
//HID人机交互设备-鼠标键盘等
#include <hidclass.h>
//GUID_DEVINTERFACE_KEYBOARD
#include <ntddkbd.h>
//GUID_DEVINTERFACE_MOUSE
#include <ntddmou.h>
#include <QCoreApplication>
#pragma comment(lib, "user32.lib")
#pragma comment(lib, "setupapi.lib")static const GUID GUID_DEVINTERFACE_LIST[] =
{// GUID_DEVINTERFACE_CAMERA_DEVICE { 0x65E8773D, 0x8F56, 0x11D0, { 0xA3, 0xB9, 0x00, 0xA0, 0xC9, 0x22, 0x31, 0x96 } },// GUID_DEVINTERFACE_USB_DEVICE { 0xA5DCBF10, 0x6530, 0x11D2, { 0x90, 0x1F, 0x00, 0xC0, 0x4F, 0xB9, 0x51, 0xED } },// GUID_DEVINTERFACE_DISK { 0x53f56307, 0xb6bf, 0x11d0, { 0x94, 0xf2, 0x00, 0xa0, 0xc9, 0x1e, 0xfb, 0x8b } },// GUID_DEVINTERFACE_HID, { 0x4D1E55B2, 0xF16F, 0x11CF, { 0x88, 0xCB, 0x00, 0x11, 0x11, 0x00, 0x00, 0x30 } },// GUID_NDIS_LAN_CLASS { 0xad498944, 0x762f, 0x11d0, { 0x8d, 0xcb, 0x00, 0xc0, 0x4f, 0xc3, 0x35, 0x8c } },// GUID_DEVINTERFACE_COMPORT{ 0x86e0d1e0, 0x8089, 0x11d0, { 0x9c, 0xe4, 0x08, 0x00, 0x3e, 0x30, 0x1f, 0x73 } },// GUID_DEVINTERFACE_SERENUM_BUS_ENUMERATOR{ 0x4D36E978, 0xE325, 0x11CE, { 0xBF, 0xC1, 0x08, 0x00, 0x2B, 0xE1, 0x03, 0x18 } },// GUID_DEVINTERFACE_PARALLEL{ 0x97F76EF0, 0xF883, 0x11D0, { 0xAF, 0x1F, 0x00, 0x00, 0xF8, 0x00, 0x84, 0x5C } },// GUID_DEVINTERFACE_PARCLASS{ 0x811FC6A5, 0xF728, 0x11D0, { 0xA5, 0x37, 0x00, 0x00, 0xF8, 0x75, 0x3E, 0xD1 } }
};VideoNotificationClient::VideoNotificationClient(VideoNotificationClient::Listener *listener): QWidget(nullptr), m_bInitialized(false), m_pListener(listener)
{this->hide();qApp->installNativeEventFilter(this);
}VideoNotificationClient::~VideoNotificationClient()
{uninstallFilter();qApp->removeNativeEventFilter(this);
} void VideoNotificationClient::initialized()
{HANDLE winid = (HANDLE)this->winId();if (!winid){return;}//注册插拔事件HDEVNOTIFY hDevNotify;DEV_BROADCAST_DEVICEINTERFACE NotifacationFiler;ZeroMemory(&NotifacationFiler, sizeof(DEV_BROADCAST_DEVICEINTERFACE));NotifacationFiler.dbcc_size = sizeof(DEV_BROADCAST_DEVICEINTERFACE);NotifacationFiler.dbcc_devicetype = DBT_DEVTYP_DEVICEINTERFACE;for (int i = 0; i < sizeof(GUID_DEVINTERFACE_LIST) / sizeof(GUID); i++){NotifacationFiler.dbcc_classguid = GUID_DEVINTERFACE_LIST[i];hDevNotify = RegisterDeviceNotification(winid, &NotifacationFiler, DEVICE_NOTIFY_WINDOW_HANDLE);if (!hDevNotify){qDebug() << "注册失败" << endl;m_bInitialized = false;return;}m_hSevNotifys.insert(QUuid(NotifacationFiler.dbcc_classguid), hDevNotify);}m_bInitialized = true;}void VideoNotificationClient::uninstallFilter()
{for (HDEVNOTIFY handle : qAsConst(m_hSevNotifys)){::UnregisterDeviceNotification(handle);}m_hSevNotifys.clear();
}bool VideoNotificationClient::nativeEventFilter(const QByteArray& eventType, void* message, long* result)
{Q_UNUSED(result);MSG* msg = reinterpret_cast<MSG*>(message);int msgType = msg->message;if (msgType == WM_DEVICECHANGE){PDEV_BROADCAST_HDR lpdb = (PDEV_BROADCAST_HDR)msg->lParam;switch (msg->wParam) {case DBT_DEVICEARRIVAL:if (lpdb->dbch_devicetype = DBT_DEVTYP_DEVICEINTERFACE){PDEV_BROADCAST_DEVICEINTERFACE pDevInf = (PDEV_BROADCAST_DEVICEINTERFACE)lpdb;GUID cameraGuid = { 0x65E8773D, 0x8F56, 0x11D0, { 0xA3, 0xB9, 0x00, 0xA0, 0xC9, 0x22, 0x31, 0x96 } };if (cameraGuid == pDevInf->dbcc_classguid){QString devicePath = QString::fromWCharArray(pDevInf->dbcc_name);QStringList parts = devicePath.split('#');if (parts.length() != 4){qDebug() << "camera logic error";return false;}QString usbPortStr = parts[2];QStringList usbPortParts = usbPortStr.split('&');if (usbPortParts.length() != 4){qDebug() << "camera logic error";return false;} if ("0000" != usbPortParts[3]){return false;}devicePath = devicePath.toLower();m_pListener->onDeviceAdded(devicePath.toStdString());//emit cameraPlugged(true, devicePath);}}break;case DBT_DEVICEREMOVECOMPLETE:if (lpdb->dbch_devicetype = DBT_DEVTYP_DEVICEINTERFACE){PDEV_BROADCAST_DEVICEINTERFACE pDevInf = (PDEV_BROADCAST_DEVICEINTERFACE)lpdb;GUID cameraGuid = { 0x65E8773D, 0x8F56, 0x11D0, { 0xA3, 0xB9, 0x00, 0xA0, 0xC9, 0x22, 0x31, 0x96 } };if (cameraGuid == pDevInf->dbcc_classguid){// USB拔出马上触发QString devicePath = QString::fromWCharArray(pDevInf->dbcc_name);QStringList parts = devicePath.split('#');if (parts.length() != 4){qDebug() << "camera logic error";return false;}QString usbPortStr = parts[2];QStringList usbPortParts = usbPortStr.split('&');if (usbPortParts.length() != 4){qDebug() << "camera logic error";return false;}if ("0000" != usbPortParts[3]){return false;}devicePath = devicePath.toLower();m_pListener->onDeviceRemoved(devicePath.toStdString());}}break;}}return false;
}
四、设备插拔库设计
项目完整代码,在后续的文章中给出

// camra
struct CameraDevice
{int nIndex = 0; // indexstd::string uid = ""; // 硬件层uniqueId mac中为BuiltInMicrophoneDevice std::string name = ""; // 设备名称
};// IVideoCore
class VIDEODEVICE_SHARED_EXPORT IVideoCore
{
public:static IVideoCore* getInstance();virtual void addListener(IVideoDeviceListner* listner) = 0;virtual void removeListener(IVideoDeviceListner* listner) = 0;virtual std::map<std::string, CameraDevice> getVideoDevicesList() = 0;
};// IVideoDevice
class VIDEODEVICE_SHARED_EXPORT IVideoDevice
{
public:virtual bool initlized() = 0;virtual std::map<std::string, CameraDevice> getVideoDevicesList() = 0;virtual void addListener(IVideoDeviceListner* listener) = 0;virtual void removeListener(IVideoDeviceListner* listener) = 0;};// IVideoDeviceListner
class VIDEODEVICE_SHARED_EXPORT IVideoDeviceListner
{
public:virtual void onDeviceAdded(CameraDevice device) = 0;virtual void onDeviceRemoved(CameraDevice device) = 0;virtual void onDeviceListUpdate(std::map<std::string, CameraDevice> cameraList) = 0;
};class VideoNotificationClient
{
public:class Listener{public:virtual void onDeviceAdded(const std::string& uid) = 0;virtual void onDeviceRemoved(const std::string& uid) = 0;};
}
相关文章:
三、视频设备的枚举以及插拔检测
一、前言 本章主要讲述,如何获取设备名称以及guid,采集设备的采集格式识别,设备的插拔 设备列表以及属性的获取使用的directshow(后续的MediaFoundation无法获取OBS摄像头) 设备的插拔使用的是QT 捕获系统消息,捕获到设备插拔后&a…...
Qt开发_调用OpenCV(4.x)完成人脸检测并绘制马赛克(摄像头实时数据)
一、前言 这个基于Qt和OpenCV的人脸检测和人脸打码项目是通过实时视频流中的人脸识别来保护隐私。 该项目目的是保护隐私并确保人脸数据安全。在某些情况下,使用实时视频流进行人脸检测和识别可能涉及对个人隐私的侵犯。通过在图像中打码人脸区域,可以避免未经许可的人脸出…...
ssl证书有效期为什么越来越短?
随着互联网的发展和网络安全意识的提升,SSL证书在保护网络通信安全方面起到了极为重要的作用。SSL证书通过加密通信数据,确保信息在传输过程中不被窃取或篡改,为用户提供了安全可靠的网络环境。然而,近年来SSL证书的有效期不断缩短…...
XFF漏洞利用([SWPUCTF 2021 新赛]Do_you_know_http)
原理 常见的存在HTTP头注入的参数 User-Agent:使得服务器能够识别客户使用的操作系统,浏览器版本等.(很多数据量大的网站中会记录客户使用的操作系统或浏览器版本等存入数据库中) Cookie:网站为了辨别用户身份进行se…...
Java——》JVM对原生的锁做了哪些优化
推荐链接: 总结——》【Java】 总结——》【Mysql】 总结——》【Redis】 总结——》【Kafka】 总结——》【Spring】 总结——》【SpringBoot】 总结——》【MyBatis、MyBatis-Plus】 总结——》【Linux】 总结——》【MongoD…...
华为云云耀云服务器L实例评测|用docker搭建frp服务测试
华为云云耀云服务器L实例评测|用docker搭建frp服务测试 0. 环境 华为云耀云L实例EulerOS 1. 安装docker 检查yum源,本EulerOS的源在这里: cd /etc/yum.repos.d 更新源 yum makecache 安装 yum install -y docker-engine 运行测试 d…...
群狼调研(长沙满意度调查专业公司)开展公交车乘客满意度调查
在城市的喧嚣中,公交车是我们日常出行的重要工具。那么,公交车的服务是否满足我们的期待?它还有哪些改进的空间?在这篇文章中,群狼调研**(长沙汽车制造商满意度调查)**通过一个深入的公交车乘客满意度调查,为您解答这些问题。 …...
Spring与OAuth2:实现第三方认证和授权的最佳实践
🌷🍁 博主猫头虎(🐅🐾)带您 Go to New World✨🍁 🦄 博客首页——🐅🐾猫头虎的博客🎐 🐳 《面试题大全专栏》 🦕 文章图文…...
Mysql的定时备份与恢复
MySQL定时备份与还原 MySQL备份方案 物理备份:这种备份方式将数据库的实际文件复制到另一个位置。常见的物理备份工具包括mysqldump和Percona XtraBackup。具体操作如下: 使用mysqldump 命令备份数据库: mysqldump -u username -p database_…...
学习Java基础面试题第五天
一、Java面试题第五天 1.spring字符串的最大长度 转自:String字符串的最大长度是多少?在学习和开发过程中,我们经常会讨论 short ,int 和 long 这些基本数据类型的取值范围,但是对于 String 类型我们好像很少注意它的…...
(10)(10.9) 术语表(一)
文章目录 前言 1 2.4Ghz 2 AGL 3 AHRS 4 APM 5 AMA 6 Arduino 7 APM (AutoPilot Mega) 8 ATC 9 Copter 10 Plane 11 Rover 12 BEC 13 Bootloader 14 COA 15 DCM 16 Eagle file 17 ESC 18 Firmware 19 FPV 20 FTDI 前言 !Note 术语表未编入索…...
面试(类加载器)
一、目标 类加载器(ClassLoader)是Java虚拟机(JVM)的一部分,用于加载Java类文件到内存中,并生成对应的Class对象。类加载器负责在运行时查找和加载类文件,为Java程序提供动态加载和运行时扩展的…...
二维差分---基础算法
书接上回 a二维数组是b二维数组的前缀和数组,b二维数组是a二维数组的差分数组,也就是说a[i][j]b[1][1]b[1][2] ......b[i][1] b[i][2] ...... b[i][j] ,下图是b的二维数组 如图,当你想要整个矩阵中的一个子矩阵都加上一个C,如果我们将b[x1][x2]加上C,那么a数组右下角所有的…...
C++之结构体智能指针shared_ptr实例(一百九十四)
简介: CSDN博客专家,专注Android/Linux系统,分享多mic语音方案、音视频、编解码等技术,与大家一起成长! 优质专栏:Audio工程师进阶系列【原创干货持续更新中……】🚀 人生格言: 人生…...
初出茅庐的小李博客之根据编译时间生成软件版本号
为什么要软件版本号呢? 生成软件版本号是在软件开发和维护过程中非常重要的一项任务,它有很多意义和好处,同时也有多种常见的方法。 标识和追踪:软件版本号是唯一的标识符,用于区分不同版本的软件。这有助于开发人员和…...
“投资教父”熊晓鸽老了,IDG光环不再
作者 | 鸠白 艺馨 排版 | Cathy 监制 | Yoda 出品 | 不二研究 2017年,世界互联网大会上,“投资教父”熊晓鸽问映客的创始人:“今年你们利润能有多少?” 对方笑答:“5个亿吧!” “才五个亿?…...
XEX智能交易所:加密货币衍生品杠杆、期货和期权简介
加密货币衍生品杠杆、期货和期权简介 加密货币衍生品是指通过基于区块链技术的交易平台进行交易的各种金融工具。与传统金融衍生品类似,加密货币衍生品的交易方式是基于预测未来市场价格变动的套利策略。接下来将具体介绍不同类型的加密货币衍生品以及风险。 加密…...
记录第一次带后端团队
在过去的一个半月里我第一次作为后端开发组长角色参与公司项目从0到1的开发,记录这一次开发的经历。 1、背景介绍 首先说明一下背景。我所在的公司是做智慧社区相关业务,开发的项目是系统升级工具,方便公司实施同事安装和升级系统。 参与后…...
Python文件操作(02):读文件
一、读文本文件 打开文件读文件内容关闭文件 1、在读取文件内容后进行解码操作 """ 1. 打开文件- 路径:相对路径:当前项目(读文件.py)所在的目录下查找需要读取的文件绝对路径:文件--右键--Copy Pat…...
Flink(java版)
watermark 时间语义和 watermark 注意:数据进入flink的时间:如果用这个作为时间语义就不存在问题,但是开发中往往会用处理时间 作为时间语义这里就需要考虑延时的问题。 如上图,数据从kafka中获取出来,从多个分区中获取…...
Python爬虫实战:研究MechanicalSoup库相关技术
一、MechanicalSoup 库概述 1.1 库简介 MechanicalSoup 是一个 Python 库,专为自动化交互网站而设计。它结合了 requests 的 HTTP 请求能力和 BeautifulSoup 的 HTML 解析能力,提供了直观的 API,让我们可以像人类用户一样浏览网页、填写表单和提交请求。 1.2 主要功能特点…...
2.Vue编写一个app
1.src中重要的组成 1.1main.ts // 引入createApp用于创建应用 import { createApp } from "vue"; // 引用App根组件 import App from ./App.vue;createApp(App).mount(#app)1.2 App.vue 其中要写三种标签 <template> <!--html--> </template>…...
大语言模型如何处理长文本?常用文本分割技术详解
为什么需要文本分割? 引言:为什么需要文本分割?一、基础文本分割方法1. 按段落分割(Paragraph Splitting)2. 按句子分割(Sentence Splitting)二、高级文本分割策略3. 重叠分割(Sliding Window)4. 递归分割(Recursive Splitting)三、生产级工具推荐5. 使用LangChain的…...
使用van-uploader 的UI组件,结合vue2如何实现图片上传组件的封装
以下是基于 vant-ui(适配 Vue2 版本 )实现截图中照片上传预览、删除功能,并封装成可复用组件的完整代码,包含样式和逻辑实现,可直接在 Vue2 项目中使用: 1. 封装的图片上传组件 ImageUploader.vue <te…...
[10-3]软件I2C读写MPU6050 江协科技学习笔记(16个知识点)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16...
Cloudflare 从 Nginx 到 Pingora:性能、效率与安全的全面升级
在互联网的快速发展中,高性能、高效率和高安全性的网络服务成为了各大互联网基础设施提供商的核心追求。Cloudflare 作为全球领先的互联网安全和基础设施公司,近期做出了一个重大技术决策:弃用长期使用的 Nginx,转而采用其内部开发…...
NLP学习路线图(二十三):长短期记忆网络(LSTM)
在自然语言处理(NLP)领域,我们时刻面临着处理序列数据的核心挑战。无论是理解句子的结构、分析文本的情感,还是实现语言的翻译,都需要模型能够捕捉词语之间依时序产生的复杂依赖关系。传统的神经网络结构在处理这种序列依赖时显得力不从心,而循环神经网络(RNN) 曾被视为…...
安卓基础(aar)
重新设置java21的环境,临时设置 $env:JAVA_HOME "D:\Android Studio\jbr" 查看当前环境变量 JAVA_HOME 的值 echo $env:JAVA_HOME 构建ARR文件 ./gradlew :private-lib:assembleRelease 目录是这样的: MyApp/ ├── app/ …...
springboot整合VUE之在线教育管理系统简介
可以学习到的技能 学会常用技术栈的使用 独立开发项目 学会前端的开发流程 学会后端的开发流程 学会数据库的设计 学会前后端接口调用方式 学会多模块之间的关联 学会数据的处理 适用人群 在校学生,小白用户,想学习知识的 有点基础,想要通过项…...
20个超级好用的 CSS 动画库
分享 20 个最佳 CSS 动画库。 它们中的大多数将生成纯 CSS 代码,而不需要任何外部库。 1.Animate.css 一个开箱即用型的跨浏览器动画库,可供你在项目中使用。 2.Magic Animations CSS3 一组简单的动画,可以包含在你的网页或应用项目中。 3.An…...
