Zoom Windows Meeting SDK Custom UI 视频渲染实战:基于 ICustomizedVideoContainer 构建自定义会议界面

发布时间:2026/9/13 13:49:56
Zoom Windows Meeting SDK Custom UI 视频渲染实战:基于 ICustomizedVideoContainer 构建自定义会议界面 Zoom Windows Meeting SDK Custom UI 视频渲染实战基于 ICustomizedVideoContainer 构建自定义会议界面【免费下载链接】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 SDKC 原生 SDKCustom UI 模式下的完整可运行示例指南配套文档位于 custom-ui-video-rendering.md。阅读本文后你将掌握如何用自有 Win32 窗口替代 SDK 默认会议 UI通过ICustomizedUIMgr、ICustomizedVideoContainer、ICustomizedShareRender三件套实现活动发言人自动跟随、参与者画廊、屏幕共享渲染与正确的生命周期管理。一、Custom UI 模式解决了什么问题Zoom Windows Meeting SDK 默认会创建自己的会议窗口与完整 UI。但在嵌入式桌面应用、品牌定制客户端、会议机器人等场景中我们需要完全掌控窗口外观与布局。Custom UI 模式的核心思路是SDK 负责视频解码与 Direct3D 渲染你的应用负责窗口创建、布局与交互——双方各司其职。关键前提有两个必须在InitSDK时设置ENABLE_CUSTOMIZED_UI_FLAG否则 SDK 仍会弹出默认会议窗口这不是“HWND 劫持”——SDK 会在你的父窗口内部创建子 HWND 并用自己的 D3D 管线渲染你的窗口与 WndProc 完全不受干扰。详细架构说明见 Custom UI Architecture。在动手写代码之前请先确认项目环境满足 Windows Reference 的要求Windows 10、Visual Studio 2019/2022、vcpkg 安装 jsoncpp 与 curl、sdk.lib链接并已完成 JWT 认证流程见 Authentication Pattern。二、整体流程与状态机本文示例应用的整体调用链如下InitSDK (with ENABLE_CUSTOMIZED_UI_FLAG) - AuthSDK (JWT) - JoinMeeting - OnConnecting: 创建窗口 CustomUIMgr VideoContainer - OnInMeeting: 创建视频元素 订阅参与者 - Message loop (窗口事件 SDK 回调) - OnEnded: 销毁所有对象需要特别强调的是SDK 回调依赖 Windows 消息泵派发。如果在等待onAuthenticationReturn、onMeetingStatusChanged时没有运行PeekMessage/GetMessage循环回调会被排队但永不派发表现为认证超时、会议加入卡死。这是 Custom UI 应用中最高频的坑详见 Windows Message Loop。主循环建议采用非阻塞模式MSG msg; while (!g_exit) { while (PeekMessage(msg, NULL, 0, 0, PM_REMOVE)) { if (msg.message WM_QUIT) { g_exit true; break; } TranslateMessage(msg); DispatchMessage(msg); } // 其它工作如布局重算 std::this_thread::sleep_for(std::chrono::milliseconds(100)); }三、Step 1初始化时启用 Custom UIInitParam中的obConfigOpts.optionalFeatures是进入 Custom UI 模式的开关缺了它后面所有 Custom UI API 都不生效InitParam initParam; initParam.strWebDomain Lhttps://zoom.us; initParam.emLanguageID LANGUAGE_English; initParam.enableLogByDefault true; // CRITICAL: This is what makes it Custom UI mode initParam.obConfigOpts.optionalFeatures ENABLE_CUSTOMIZED_UI_FLAG; SDKError err InitSDK(initParam);从源码结构看InitParam还支持strSupportUrl、enableGenerateDump崩溃转储、uiLogFileSize日志文件大小默认 5MB等字段完整字段表见 windows-reference.md 的 InitParam 结构。渲染后端可通过InitParam.renderOpts.videoRenderMode指定优先级为 D3D11 FLIP D3D11 D3D9 GDIGDI 仅用于虚拟机等无 GPU 场景默认ZoomSDKVideoRenderMode_None即自动选择。四、Step 2在 CONNECTING 状态创建 Custom UI 管理器与视频容器时机很关键必须在MEETING_STATUS_CONNECTING回调中创建而不是MEETING_STATUS_INMEETING——SDK 需要在开始渲染前就绪视频容器。官方 SDK 示例中ICustomizedUIMgr::HasLicense()被视为硬性门槛但实践中现代 SDK 许可通常默认包含 Custom UI建议仅记录警告而不中止真正缺许可时后续 API 调用会返回错误#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; // Create the manager (global SDK function) SDKError err CreateCustomizedUIMgr(pCustomUIMgr); // Optional: check license (log warning, dont abort) err pCustomUIMgr-HasLicense(); if (err ! SDKERR_SUCCESS) { std::cout WARNING: HasLicense returned err std::endl; } // Register for destroy notifications pCustomUIMgr-SetEvent(myUIMgrEventListener); // Create video container inside your Win32 window RECT rc; ::GetClientRect(hMyWindow, rc); err pCustomUIMgr-CreateVideoContainer(pVideoContainer, hMyWindow, rc); pVideoContainer-SetEvent(myVideoContainerEventListener); pVideoContainer-Show(); pVideoContainer-SetBkColor(RGB(30, 30, 30)); // Dark background架构层面的关键事实CreateVideoContainer(hParentWnd, rc)会在你的父窗口内部创建一个子 HWND你永远不需要直接管理这个子句柄视频元素并不是独立窗口而是单个 D3D 表面上的“逻辑渲染区域”SetPos(RECT)告诉 SDK 合成器把每路视频纹理放到容器内的哪个位置你的应用零渲染工作——不需要WM_PAINT、GDI 调用或BitBltSDK 内部完成 100% 的视频绘制相关 DLL 包括zVideoUI.dll、zVideoApp.dll、avcodec_zm-59.dll等见 custom-ui-architecture.md。五、Step 3在 IN_MEETING 状态创建视频元素进入会议后MEETING_STATUS_INMEETING通过参与者控制器拿到用户列表然后创建两类元素。5.1 活动发言人元素自动跟随当前说话人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(); // Begin auto-tracking active speaker注意Show()只是让元素可见必须再调用Start()才开始自动跟踪发言人Stop()可暂停跟踪。5.2 普通元素绑定具体参与者普通元素必须调用Subscribe(userId)绑定到具体用户否则画面空白同时用SetResolution()控制拉流分辨率示例使用VideoRenderResolution_360pIMeetingParticipantsController* 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(); // Position in gallery strip int elemWidth windowWidth / galleryCount; RECT r { i * elemWidth, galleryTop, (i1) * elemWidth, windowHeight }; pNormal-SetPos(r); }关于其他元素类型VideoRenderElement_PREVIEW用于入会前本地摄像头预览ICustomizedVideoContainer也支持CreatePreviewVideoElement系列接口。SDK 还提供ICustomizedImmersiveContainer沉浸式容器用于 3D 场景嵌入。若需要像素级自渲染滤镜、画中画、独立窗口可参考 Raw Video Capture两种方案对比见 SDK-Rendered vs Self-Rendered。六、Step 4布局管理——响应容器缩放与窗口尺寸变化布局的核心原则SetPos()的坐标是相对于容器客户区的不是屏幕坐标也不是父窗口坐标。当容器收到onLayoutNotification(RECT wnd_client_rect)或窗口收到WM_SIZE时都要重新计算所有元素位置void LayoutVideoElements() { RECT clientRect; ::GetClientRect(hMyWindow, clientRect); int totalWidth clientRect.right - clientRect.left; int totalHeight clientRect.bottom - clientRect.top; // Resize container to fill window pVideoContainer-Resize(clientRect); if (galleryElements.empty()) { // Active speaker only — full window RECT activeRect { 0, 0, totalWidth, totalHeight }; pActiveElement-SetPos(activeRect); } else { // Active speaker: top 70%, gallery: bottom 30% int activeHeight (int)(totalHeight * 0.7); RECT activeRect { 0, 0, totalWidth, activeHeight }; pActiveElement-SetPos(activeRect); 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); } } }窗口 resize 时务必同步Resize()容器否则 D3D 表面尺寸与窗口不匹配会出现拉伸、黑边或渲染错位。输入消息转发为什么要在 onWindowMsgNotification 处理点击SDK 子 HWND 拥有自己的 WndProc会截获落在视频区域上的鼠标/键盘消息WM_MOUSEMOVE、WM_LBUTTONDOWN、WM_LBUTTONUP、WM_RBUTTONUP、WM_LBUTTONDBLCLK、WM_KEYDOWN等你的父窗口 WndProc 永远看不到这些消息。SDK 通过ICustomizedVideoContainerEvent::onWindowMsgNotification将它们回传给你——如果需要在点击视频时选中某位参与者必须在这个回调里处理。七、Step 5屏幕共享渲染ICustomizedShareRender共享渲染是一个独立的 SDK 子窗口/D3D 表面建议在创建容器后立即创建并隐藏等有人共享时再显示// Create share render (hidden until someone shares) ICustomizedShareRender* pShareRender nullptr; RECT rc; ::GetClientRect(hMyWindow, rc); pCustomUIMgr-CreateShareRender(pShareRender, hMyWindow, rc); pShareRender-SetEvent(myShareEventListener); pShareRender-Hide(); // In ShareRenderEventListener: void onSharingSourceNotification(unsigned int nShareSourceID) { if (nShareSourceID 0) { pShareRender-SetShareSourceID(nShareSourceID); pShareRender-SetViewMode(CSM_FULLFILL); pShareRender-Show(); } else { pShareRender-Hide(); } }两个细节值得注意onSharingSourceNotification携带新的共享源 ID 时调用SetShareSourceIDShow()当共享停止时nShareSourceID为 0应Hide()ICustomizedShareRender独有HandleWindowsMoveMsg()——D3D swap chain 的呈现位置经由 DWM 合成关联到窗口屏幕坐标父窗口移动时子 HWND 会自动跟随但 swap chain 的 DWM 表面坐标可能不会立即更新产生“残影帧”伪影调用该方法可强制在新坐标重呈现。该接口只存在于 Share Render视频容器内部已处理或使用 FLIP 模型天然无此问题。八、Step 6会议结束时的清理顺序销毁顺序不能乱先销毁所有视频元素 → 销毁容器 → 销毁共享渲染 → 销毁管理器 → 销毁窗口void Cleanup() { if (pVideoContainer) { pVideoContainer-DestroyAllVideoElement(); pCustomUIMgr-DestroyVideoContainer(pVideoContainer); pVideoContainer nullptr; } if (pShareRender) { pCustomUIMgr-DestroyShareRender(pShareRender); pShareRender nullptr; } if (pCustomUIMgr) { DestroyCustomizedUIMgr(pCustomUIMgr); pCustomUIMgr nullptr; } if (hMyWindow) { DestroyWindow(hMyWindow); hMyWindow nullptr; } }还需要注意SDK 也可能自行销毁容器例如会议结束这正是ICustomizedUIMgrEvent提供onVideoContainerDestroyed和onShareRenderDestroyed回调的原因——务必在这两个回调中将你的指针置空避免悬垂引用。九、必须实现的监听器接口3 个接口共 12 个纯虚方法Custom UI 模式要求实现以下接口的全部纯虚方法漏掉任何一个都会导致编译期“cannot instantiate abstract class”错误 C2259。各方法签名、参数类型与版本差异的处理方式详见 Interface Methods Reference。ICustomizedUIMgrEvent3 个方法方法触发时机建议动作onVideoContainerDestroyed(ICustomizedVideoContainer*)SDK 自行销毁容器如会议结束置空容器指针onShareRenderDestroyed(ICustomizedShareRender*)SDK 自行销毁共享渲染置空共享渲染指针onImmersiveContainerDestroyed()沉浸式容器被销毁置空相关指针ICustomizedVideoContainerEvent6 个方法方法作用onRenderUserChanged(IVideoRenderElement*, unsigned int userid)元素绑定的用户变化onRenderDataTypeChanged(IVideoRenderElement*, VideoRenderDataType)数据类型变化VideoRenderData_Video/VideoRenderData_Avatar/VideoRenderData_ScreenNameonLayoutNotification(RECT wnd_client_rect)容器尺寸变化重算所有元素位置onVideoRenderElementDestroyed(IVideoRenderElement*)某视频元素被销毁onWindowMsgNotification(UINT, WPARAM, LPARAM)SDK 子 HWND 转发的输入消息onSubscribeUserFail(ZoomSDKVideoSubscribeFailReason, IVideoRenderElement*)视频订阅失败原因枚举ViewOnly、NotInMeeting、HasSubscribe1080POr720、HasSubscribeTwo720P、HasSubscribeExceededLimit、TooFrequentCallICustomizedShareRenderEvent3 个方法方法作用onSharingContentStartReceiving()开始接收共享内容onSharingSourceNotification(unsigned int nShareSourceID)共享源变化ID 为 0 表示共享结束onWindowMsgNotification(UINT, WPARAM, LPARAM)共享渲染子 HWND 的输入转发十、Required SDK Headers头文件包含顺序SDK 头文件之间存在强依赖顺序顺序错误会引发uint32_t未定义、AudioType未知等编译错误windows.h必须是第一个cstdint紧随其后提供uint32_t#include windows.h #include cstdint #include zoom_sdk.h #include customized_ui/zoom_customized_ui.h // CreateCustomizedUIMgr() #include customized_ui/customized_ui_mgr.h // ICustomizedUIMgr, ICustomizedUIMgrEvent #include customized_ui/customized_video_container.h // ICustomizedVideoContainer, elements #include customized_ui/customized_share_render.h // ICustomizedShareRender #include meeting_service_interface.h #include meeting_service_components/meeting_audio_interface.h // Before participants! #include meeting_service_components/meeting_participants_ctrl_interface.hmeeting_audio_interface.h必须在meeting_participants_ctrl_interface.h之前包含——这是 Windows SDK 已知的依赖陷阱。完整的 Visual Studio 工程配置Include/Lib 目录、预处理器定义、Post-Build 拷贝 DLL 事件可参考 SKILL.md 与 windows-reference.md部署分发时的 VC 运行库与签名注意事项见 Deployment Guide。十一、关键陷阱清单Key GotchasCustom UI 必须在 CONNECTING 而非 IN_MEETING 创建——SDK 需要视频容器在开始渲染前就绪活动元素需要Start()——仅Show()不会开始发言人跟踪普通元素需要Subscribe(userId)——不订阅画面为空SetPos()坐标相对容器不是屏幕或父窗口坐标窗口 resize 时同步Resize()容器——否则 D3D 表面与窗口不匹配销毁顺序有讲究——元素 → 容器 → 管理器不要忘记 Windows 消息泵——没有PeekMessage/DispatchMessage所有 SDK 回调都不会触发监听器必须实现全部纯虚方法含#if defined(WIN32)包裹的 Windows 专有方法并用override关键字捕获签名不匹配。十二、延伸阅读Custom UI Architecture —— 渲染内部原理子 HWND、D3D 管线、HandleWindowsMoveMsg成因Two Approaches: SDK-Rendered vs Self-Rendered —— SDK 渲染与原始帧自渲染的选型决策Raw Video Capture —— 自渲染方案IZoomSDKRenderer YUV420 帧Interface Methods Reference —— 全部必需虚方法清单与实现模板Windows Message Loop —— 回调不触发的根因与修复Common Issues —— 错误码速查与诊断流程说明本文基于仓库内文档对应 Zoom Windows Meeting SDK v6.7.2.26830 编写不同 SDK 版本的接口方法集合可能有增减升级时请以实际 SDK 头文件grep 0 SDK/x64/h/*.h为准核对签名。【免费下载链接】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),仅供参考