Custom UI 视频渲染:SDK 渲染与 Raw Data 自渲染双路径详解)
Zoom Meeting SDKWindowsCustom UI 视频渲染SDK 渲染与 Raw Data 自渲染双路径详解【免费下载链接】knowledge-work-pluginsOpen source repository of plugins primarily intended for knowledge workers to use in Claude Cowork项目地址: https://gitcode.com/GitHub_Trending/kn/knowledge-work-plugins在 Zoom Windows Meeting SDK 的 Custom UI 模式下构建自绘会议界面时谁来渲染视频是第一个必须回答的架构问题。本文基于 SDK-Rendered vs Self-Rendered 概念文档Zoom Meeting SDK Windows 技能基于 SDK v6.7.2.26830 编写完整拆解两条技术路径——ICustomizedVideoContainer的 SDK 渲染路径与IZoomSDKRenderer的 Raw Data 自渲染路径——各自的工作原理、关键 API、权限模型、YUV420 数据格式与性能约束并结合仓库中 Custom UI 完整代码示例 与 Raw Video Capture 完整示例 给出可直接复制的实操代码与决策矩阵。读完本文你可以针对自己的场景标准会议窗口、特效处理、多窗口布局、计算机视觉分析、带水印录制选定渲染路径并掌握从初始化到清理的完整落地流程。总览两种方式的共同前提与本质差异构建 Custom UI 的两种方式在起点上完全相同都必须在InitSDK阶段开启 Custom UI 模式ENABLE_CUSTOMIZED_UI_FLAG二者的本质区别仅在于视频的像素由谁绘制。InitParam initParam; initParam.strWebDomain Lhttps://zoom.us; initParam.emLanguageID LANGUAGE_English; // CRITICAL: Enable Custom UI mode initParam.obConfigOpts.optionalFeatures ENABLE_CUSTOMIZED_UI_FLAG; SDKError err InitSDK(initParam);不带此标志时SDK 会创建自己的默认会议窗口带上此标志后SDK 不再创建任何 UI——窗口、控件、布局全部由你的应用提供。从 Custom UI 架构文档 的内部实现看Custom UI不是HWND 劫持SDK 会在你的父窗口内部创建子窗口child HWND并用其私有的 D3D 渲染管线向子窗口绘制视频你自己的窗口和WndProc保持不受干扰。两条路径对应的内部数据流分别是SDK 渲染路径 Your Win32 Window - SDK creates child HWND inside it - SDK renders video via D3D11 into child HWND - You call SetPos(RECT) to position video elements 自渲染路径 SDK decodes video internally - IZoomSDKRendererDelegate::onRawDataFrameReceived(YUVRawDataI420*) - You get raw pixel data (Y, U, V planes) - You render with your own engine (D3D11, OpenGL, GDI, etc.)另一个值得了解的事实SDK 支持多种渲染后端可通过InitParam.renderOpts.videoRenderMode配置优先级为D3D11 FLIP D3D11 D3D9 GDIGDI 为软件回退适用于虚拟机等环境。D3D11 FLIP 模式使用DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL这要求专用的子 HWND——进一步印证了上述子窗口架构。路径一SDK 渲染ICustomizedVideoContainer——你控布局SDK 控像素职责划分SDK 用 Direct3D 把视频渲染进你的窗口。你在布局层面拥有控制力像素层面完全交给 SDK你控制的窗口的创建、尺寸、定位哪些参会者可见、显示在哪里SetPos活跃发言人视图 vs 画廊视图的布局你自己的 UI 控件按钮、工具栏背景色SetBkColorSDK 控制的实际的视频解码与渲染D3D 渲染管线帧时序视频质量 / 分辨率缩放这条路径下的关键 API 清单CreateCustomizedUIMgr()/DestroyCustomizedUIMgr()ICustomizedUIMgr::CreateVideoContainer()/DestroyVideoContainer()ICustomizedVideoContainer::CreateVideoElement()IActiveVideoRenderElement::Start()/Stop()INormalVideoRenderElement::Subscribe(userId)IVideoRenderElement::SetPos(RECT)/Show()/Hide()三个必须理解的架构事实CreateVideoContainer(hParentWnd, rc)由 SDK 在你的父窗口内创建子 HWND你永远不需要直接看见或管理这个子 HWNDVideo Element 不是独立窗口而是单一 D3D 表面内的逻辑渲染区域。SetPos(RECT)是告诉 SDK 的合成器把每路视频纹理放到容器的哪个位置你的应用零渲染——没有WM_PAINT、没有 GDI 调用、没有BitBlt视频绘制 100% 由 SDK 内部完成。实操从建管理器到清理的完整代码以下流程对应 Custom UI Video Rendering 示例 的完整工作代码。第 1 步在MEETING_STATUS_CONNECTING时创建管理器与容器注意时机——必须在 CONNECTING 而非 IN_MEETINGSDK 需要在开始渲染前准备好视频容器#include customized_ui/zoom_customized_ui.h #include customized_ui/customized_ui_mgr.h #include customized_ui/customized_video_container.h #include customized_ui/customized_share_render.h ICustomizedUIMgr* pCustomUIMgr nullptr; ICustomizedVideoContainer* pVideoContainer nullptr; // 全局函数创建管理器 SDKError err CreateCustomizedUIMgr(pCustomUIMgr); // 可选检查许可官方建议 log 警告而非直接中止 // 因为现代许可通常默认包含 Custom UI err pCustomUIMgr-HasLicense(); if (err ! SDKERR_SUCCESS) { std::cout WARNING: HasLicense returned err std::endl; } // 注册销毁通知SDK 可能在会议结束时自行销毁容器 pCustomUIMgr-SetEvent(myUIMgrEventListener); // 在你的 Win32 窗口内创建视频容器 RECT rc; ::GetClientRect(hMyWindow, rc); err pCustomUIMgr-CreateVideoContainer(pVideoContainer, hMyWindow, rc); pVideoContainer-SetEvent(myVideoContainerEventListener); pVideoContainer-Show(); pVideoContainer-SetBkColor(RGB(30, 30, 30)); // 深色背景第 2 步在MEETING_STATUS_INMEETING时创建 Video Element。SDK 提供三类元素元素类型用途绑定方式VideoRenderElement_ACTIVE自动跟随当前发言者无需订阅调用Start()开始跟踪VideoRenderElement_NORMAL显示指定参会者必须Subscribe(userId)VideoRenderElement_PREVIEW入会前本地摄像头预览会前相机设置// 活跃发言人元素自动跟随说话者 IVideoRenderElement* pElement nullptr; err pVideoContainer-CreateVideoElement(pElement, VideoRenderElement_ACTIVE); IActiveVideoRenderElement* pActive dynamic_castIActiveVideoRenderElement*(pElement); RECT activeRect { 0, 0, windowWidth, (int)(windowHeight * 0.7) }; pActive-SetPos(activeRect); pActive-Show(); pActive-Start(); // 关键仅 Show() 不够必须 Start() 才开始跟踪发言人 // 画廊元素每个参会者一路 IMeetingParticipantsController* pParticipants pMeetingService-GetMeetingParticipantsController(); IListunsigned int* pUserList pParticipants-GetParticipantsList(); for (int i 0; i pUserList-GetCount() i MAX_GALLERY; i) { unsigned int userId pUserList-GetItem(i); IVideoRenderElement* pNormElement nullptr; err pVideoContainer-CreateVideoElement(pNormElement, VideoRenderElement_NORMAL); INormalVideoRenderElement* pNormal dynamic_castINormalVideoRenderElement*(pNormElement); pNormal-Subscribe(userId); // 关键不订阅则什么都显示不出来 pNormal-SetResolution(VideoRenderResolution_360p); pNormal-Show(); int elemWidth windowWidth / galleryCount; RECT r { i * elemWidth, galleryTop, (i1) * elemWidth, windowHeight }; pNormal-SetPos(r); }第 3 步布局管理。Element 的RECT坐标是相对于容器客户区的不是屏幕坐标。窗口尺寸变化时WM_SIZE或onLayoutNotification回调需要重算并重放全部元素位置void LayoutVideoElements() { RECT clientRect; ::GetClientRect(hMyWindow, clientRect); int totalWidth clientRect.right - clientRect.left; int totalHeight clientRect.bottom - clientRect.top; // 窗口尺寸变化时同步容器大小否则 D3D 表面与窗口不匹配 pVideoContainer-Resize(clientRect); // 活跃发言人占顶部 70%画廊占底部 30% 均分 int activeHeight (int)(totalHeight * 0.7); pActiveElement-SetPos({ 0, 0, totalWidth, activeHeight }); int elemWidth totalWidth / (int)galleryElements.size(); for (int i 0; i galleryElements.size(); i) { RECT r { i * elemWidth, activeHeight, (i1) * elemWidth, totalHeight }; galleryElements[i]-SetPos(r); } }第 4 步屏幕共享。共享画面走独立的 SDK 子窗口ICustomizedShareRender有自己独立的 D3D 表面ICustomizedShareRender* pShareRender nullptr; pCustomUIMgr-CreateShareRender(pShareRender, hMyWindow, rc); pShareRender-SetEvent(myShareEventListener); pShareRender-Hide(); // 有人共享前保持隐藏 // 在 ICustomizedShareRenderEvent 回调中 void onSharingSourceNotification(unsigned int nShareSourceID) { if (nShareSourceID 0) { pShareRender-SetShareSourceID(nShareSourceID); pShareRender-SetViewMode(CSM_FULLFILL); // 或 CSM_LETTER_BOX pShareRender-Show(); } else { pShareRender-Hide(); } }第 5 步清理。销毁顺序严格为元素 → 容器 → 管理器if (pVideoContainer) { pVideoContainer-DestroyAllVideoElement(); pCustomUIMgr-DestroyVideoContainer(pVideoContainer); pVideoContainer nullptr; } if (pShareRender) { pCustomUIMgr-DestroyShareRender(pShareRender); pShareRender nullptr; } if (pCustomUIMgr) { DestroyCustomizedUIMgr(pCustomUIMgr); pCustomUIMgr nullptr; }必须实现的 Custom UI 事件接口按 接口方法参考Custom UI 模式下共需实现 13 个纯虚方法接口方法数SDK 头文件ICustomizedUIMgrEvent3customized_ui/customized_ui_mgr.hICustomizedVideoContainerEvent6customized_ui/customized_video_container.hICustomizedShareRenderEvent3customized_ui/customized_share_render.hICustomizedImmersiveContainerEvent1customized_ui/customized_immersive_container.h其中ICustomizedVideoContainerEvent的 6 个方法各有明确职责onRenderUserChanged元素换人、onRenderDataTypeChanged数据在视频/头像/屏名间切换、onLayoutNotification容器 resize 后重排元素、onVideoRenderElementDestroyed元素被销毁、onWindowMsgNotificationSDK 子 HWND 转发的鼠标/键盘消息、onSubscribeUserFail订阅失败及原因原因枚举包含ViewOnly、HasSubscribeExceededLimit、TooFrequentCall等。两个容易踩坑的事件要点输入消息不会进你的父窗口WndProc。SDK 子 HWND 有自己的 WndProc 并拦截输入落在视频区域上的鼠标/键盘事件由 SDK 通过onWindowMsgNotification转发给你转发WM_MOUSEMOVE、WM_LBUTTONDOWN、WM_KEYDOWN等。如需实现点击某参会者选中必须在这个回调里处理SDK 可能自行销毁容器如会议结束。ICustomizedUIMgrEvent的onVideoContainerDestroyed/onShareRenderDestroyed回调中必须将你的指针置空避免悬挂引用。适用场景与限制适合标准会议应用需要带视频的会议窗口追求快速开发代码量小不需要像素级访问的场景。限制无法访问原始视频帧不能应用自定义滤镜/特效不能把视频渲染进你自己的图形引擎不能把单个参会者弹出到独立窗口Element 只是单一容器内的区域每个容器单一渲染表面路径二自渲染IZoomSDKRenderer Raw Data——拿到原始 YUV 帧自己画工作方式与职责划分SDK 在内部解码视频然后通过回调把每路参会者的 YUV420 原始帧交给你渲染完全由你自己的管线完成——D3D11、OpenGL、Vulkan、GDI 均可。你控制的在路径一的基础上额外获得原始像素访问YUV420 帧你自己的渲染管线自定义视频处理滤镜、水印、叠加、特效每参会者独立窗口画中画Picture-in-Picture带特效的录制/推流任意可想象的布局SDK 提供的解码后的YUVRawDataI420*帧帧元数据宽、高、旋转按参会者订阅关键 API 清单createRenderer()— 为一路参会者创建 rendererIZoomSDKRenderer::setRawDataResolution()— 设置质量IZoomSDKRenderer::subscribe(userId)— 绑定到参会者IZoomSDKRendererDelegate::onRawDataFrameReceived(YUVRawDataI420*)— 帧回调IZoomSDKRendererDelegate::onRawDataStatusChanged(RawDataStatus)— 状态变化原始录制IMeetingRecordingController::StartRawRecording()权限模型Raw Recording 与 Raw Streaming 二选一与 SDK 渲染路径不同Raw Data 访问有明确的权限前提。按 Raw Video Capture 示例 的说明获取原始数据有两条许可通道方式权限来源获取方式典型用途Raw Recording本地录制权限主持人/联席主持人或经主持人授权以录制同意对话框形式获取数据Raw Streaming直播推流权限必须为 Pro/Business/Education/Enterprise 许可其他参会者看到直播提示而非录制两者的关键差异Raw Recording 会禁用 SDK 用户的本地.mp4录制主持人的云录制不受影响Raw Streaming 会让其他参会者看到live streaming通知。两者拿到的原始数据完全相同YUV420 视频 PCM 音频。SDK 版本要求以文档标注为准Raw recording 需 5.9.0主持人发起 raw streaming 需 5.11.0非主持人需 5.12.8运行时向主持人请求本地录制权限需 5.13.5。此外还可以用 OAuth App Privilege Tokenapp_privilege_token加入参数跳过向主持人请求授权步骤。实操五步拿到第一帧视频第 1 步先启动 Raw 数据源。这是最关键的约束——必须先调用StartRawRecording()或StartRawLiveStream()帧才开始流动IMeetingRecordingController* recordingCtrl meetingService-GetMeetingRecordingController(); SDKError canStart recordingCtrl-CanStartRecording(false, 0); if (canStart ! SDKERR_SUCCESS) { std::cerr [VIDEO] Cannot start recording: canStart std::endl; return; } SDKError err recordingCtrl-StartRawRecording(); // 注意StartRawRecording() 不会在磁盘创建录制文件 // 只是打开原始数据回调通道权限不足会返回 SDKERR_WRONG_USAGE std::this_thread::sleep_for(std::chrono::milliseconds(500)); // 等待初始化第 2 步确定要捕获的 userId。全量捕获就遍历GetParticipantsList()活跃发言人用IMeetingVideoController()-GetActiveVideoUserID()捕获自己用GetMySelfUser()-GetUserID()调试时建议先订阅自己的视频流最易控制。第 3 步实现 Renderer Delegate 接收帧class ZoomSDKRendererDelegate : public IZoomSDKRendererDelegate { public: // 每帧到达时调用 void onRawDataFrameReceived(YUVRawDataI420* data) override; // 原始数据状态变化 void onRawDataStatusChanged(RawDataStatus status) override; // renderer 销毁前调用 void onRendererBeDestroyed() override; };第 4 步创建 renderer 并订阅。注意使用全局函数createRenderer()不是CreateRenderer()也不是new且必须在 subscribe 之前设置分辨率IZoomSDKVideoSource* videoHelper nullptr; ZoomSDKRendererDelegate* videoDelegate new ZoomSDKRendererDelegate(); SDKError err createRenderer(videoHelper, videoDelegate); if (err ! SDKERR_SUCCESS || !videoHelper) return; // 分辨率决定 CPU/带宽占用 // ZoomSDKResolution_90P / 180P / 360P / 720P / 1080P videoHelper-setRawDataResolution(ZoomSDKResolution_720P); err videoHelper-subscribe(userId, RAW_DATA_TYPE_VIDEO); // 屏幕共享内容用 RAW_DATA_TYPE_SHARE复用同一帧回调第 5 步清理。反订阅并停止 raw 数据源renderer delegate 由 SDK 自动释放不要自己delete只需将指针置空if (videoHelper) videoHelper-unSubscribe(userId, RAW_DATA_TYPE_VIDEO); if (recordingCtrl) recordingCtrl-StopRawRecording(); videoHelper nullptr; videoDelegate nullptr;YUV420I420内存布局详解自渲染路径的核心数据是 I420 格式的原始帧Y亮度为全分辨率UCb和 VCr各为 1/4 分辨率。以 1920x1080 一帧为例总字节数1920 * 1080 * 1.5 3,110,400 bytes Y plane: [0, 2,073,599] (1920 * 1080 2,073,600 bytes) U plane: [2,073,600, 2,592,639] (960 * 540 518,400 bytes) V plane: [2,592,640, 3,110,399] (960 * 540 518,400 bytes)尺寸计算公式size_t ySize width * height; size_t uvSize width * height / 4; // (width/2) * (height/2) size_t totalSize width * height * 1.5;像素访问方式U/V 为 2x2 下采样int x 100, y 50; unsigned char yValue yBuffer[y * width x]; unsigned char uValue uBuffer[(y/2) * (width/2) (x/2)]; unsigned char vValue vBuffer[(y/2) * (width/2) (x/2)];YUV420 转 RGB自渲染显示前必须做这一步转换void YUVtoRGB(unsigned char y, unsigned char u, unsigned char v, unsigned char r, unsigned char g, unsigned char b) { int c y - 16; int d u - 128; int e v - 128; int red (298 * c 409 * e 128) 8; int green (298 * c - 100 * d - 208 * e 128) 8; int blue (298 * c 516 * d 128) 8; r (unsigned char)std::min(255, std::max(0, red)); g (unsigned char)std::min(255, std::max(0, green)); b (unsigned char)std::min(255, std::max(0, blue)); }旋转处理视频流可能带 0°/90°/180°/270° 旋转data-GetRotation()自渲染时需在显示/处理前自行旋转用 OpenCV 的话对应cv::rotate的三个方向。落盘验证技巧把 YUV 按 Y→U→V 顺序追加写入文件后可用 ffplay 回放验证格式是否正确ffplay -f rawvideo -pixel_format yuv420p -video_size 1920x1080 -framerate 30 output.yuv性能约束回调里别干重活文档给出的明确约束引自官方文档不要在 raw data 回调内执行重量级操作否则会导致数据延迟或丢失。推荐模式是快拷入队列、另线程处理void onRawDataFrameReceived(YUVRawDataI420* data) { // 快拷贝到队列立即返回 Frame copy; copy.width >IMeetingVideoController* videoCtrl meetingService-GetMeetingVideoController(); if (videoCtrl-CanEnableAlphaChannelMode()) { videoCtrl-EnableAlphaChannelMode(true); } // 帧回调中取 alpha 数据仅启用后可用 char* alphaBuffer contenteditable="false">【免费下载链接】knowledge-work-pluginsOpen source repository of plugins primarily intended for knowledge workers to use in Claude Cowork项目地址: https://gitcode.com/GitHub_Trending/kn/knowledge-work-plugins创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考