CoreVideo

An OBS Studio plugin that captures live Zoom meeting video, audio, screen share, and Zoom interpretation audio channel capture โ€” with a dockable control panel, auto-reconnect, ZoomISO-style assignment modes, hardware video acceleration, and TCP/OSC control APIs for full broadcast automation.

Zoom Raw Data Bandwidth Planning
CoreVideo uses Zoom Meeting SDK raw data APIs. Raw data access does not require Enhanced Media by itself, but quality and stream count follow Zoom account and app entitlements. Standard accounts are commonly constrained to roughly 30 Mbps incoming video; Enhanced Media / HBM can raise that envelope to roughly 100 Mbps. Plan around 4-6 Mbps per standard 1080p feed.
C++17 CMake 3.16+ OBS Studio 30+ Qt 6 Zoom SDK 5.17+ / 7.x OAuth 2.0 PKCE Windows macOS Linux

Introduction

CoreVideo integrates the Zoom Meeting SDK into OBS Studio with no screen capture or virtual camera required. It receives raw I420 video and 48 kHz PCM audio from any meeting participant, performs optional hardware-accelerated color conversion, and pushes frames into OBS as native sources.

The ZoomObsEngine child process hosts all SDK access in isolation, communicating with the plugin over JSON IPC (named pipes on Windows, Unix domain sockets on macOS/Linux) with frame data flowing through named shared memory. The plugin contains no Zoom SDK linkage.

Joining a meeting is driven by the built-in Zoom Control dock (or the TCP/OSC APIs). Once in a meeting, sources can follow a fixed participant, the active speaker, a ZoomISO-style spotlight slot, or the active screen share โ€” and switch automatically with a configurable failover participant if the primary leaves.

A ZoomReconnectManager automatically re-joins after engine crashes, network drops, or unexpected disconnects, with exponential back-off and a cancel-at-any-time UI in the dock.

For Zoom App Marketplace compliance and attributed joins, ZoomOAuthManager uses the CoreVideo HTTPS broker to run Zoom Public Client OAuth with PKCE and receive a short-lived broker token through the bundled corevideo://oauth/callback helper. Before each join, the plugin asks the broker for a short-lived Meeting SDK JWT; the broker validates the signed-in Zoom access token before minting the SDK JWT. The plugin also exposes TCP and OSC control APIs for external production automation, including source assignment, meeting control, and ISO recording.

Features

๐ŸŽฅ

Raw Video Capture

I420 YUV per participant. Selectable 360p / 720p / 1080p resolution. Hardware-accelerated color conversion (CUDA, VAAPI, VideoToolbox, QSV).

โฌ›

Video Loss & Placeholder

Hold last frame or show black on feed drop. Shows a color-bar placeholder when not yet subscribed.

๐ŸŽ™๏ธ

Raw Audio Capture

48 kHz PCM, mono or stereo, with per-participant audio isolation and mixer routing.

๐ŸŒ

Interpretation Audio Capture

Dedicated OBS source for existing Zoom interpretation audio channels in the meeting.

๐Ÿ–ฅ๏ธ

Screen Share Capture

Source type that follows active screen-share feed via ScreenShare assignment mode.

๐ŸŽฏ

Spotlight / ZoomISO Modes

Assign a source to Spotlight slot 1โ€ฆN โ€” exactly like ZoomISO. Engine resolves which participant owns each slot.

๐Ÿ”„

Active Speaker Mode

Sensitivity (ms) + hold-time (ms) debounce with liveness guard, supersede logic, and failover participant.

๐Ÿ”

Failover Participant

Configure a secondary participant ID that activates automatically when the primary leaves the meeting.

๐Ÿ”Œ

Auto-Reconnect

Automatic re-join after engine crash, network drop, or disconnection โ€” exponential back-off with cancel UI.

๐ŸŽฌ

Webinar Support

Join Zoom Webinars using the dedicated SDK entry point โ€” set the Webinar checkbox in the control dock.

๐Ÿ‘ฅ

Rich Participant Roster

Live list with video, mute, talking, host, co-host, raised hand, spotlight slot, and screen-sharing state.

๐Ÿ”Š

Per-Participant Audio

Standalone OBS audio source per meeting participant; audio mixed into OBS audio mixer.

๐ŸŽ›๏ธ

Control Dock

Dockable Qt panel with join/leave, state indicator, Active Speaker Director controls, recovery countdown, and Output Manager launch.

DIAG

Diagnostics

The dockable Zoom Diagnostics panel shows requested versus observed resolution, FPS, frame age, retry counters, and recent engine debug events.

OUT

Dockable Output Manager

Zoom Output Manager is now a persistent OBS dock for source assignments, live preview thumbnails, feed health, profile save/load, and manual recovery actions.

๐ŸŒ

TCP Control API

JSON server on port 19870 โ€” query, join, leave, assign outputs from scripts or dashboards.

๐ŸŽ›๏ธ

OSC Control API

UDP OSC server on port 19871 for lighting consoles and broadcast hardware.

๐Ÿ’พ

Output Profiles

Save and load named participant-to-source mappings as JSON files.

โŒจ๏ธ

OBS Hotkeys

Per-source hotkeys to enable/disable active speaker mode from OBS keyboard shortcuts.

๐Ÿ”’

Hardened Security

Constant-time token comparison, validated IPC input, sanitised participant IDs, SIGPIPE handling, DPAPI token storage (Windows).

๐Ÿ”‘

Zoom OAuth PKCE

Broker-backed OAuth 2.0 with S256 PKCE for attributed joins and Marketplace compliance. Meeting SDK JWTs are minted server-side after token validation. Custom URL scheme with platform callback helpers.

๐ŸŽจ

Modern UI

CoreVideo dark stylesheet with animated CvStatusDot, CvBanner first-run notices, and button role variants (primary / danger).

ISO

Auto ISO Recording

Record assigned participant video/audio tracks to separate files while optionally recording the main OBS program output.

๐Ÿ’ป

Multi-Platform

Windows (x64/arm64), macOS (universal arm64+x86_64), Linux. SDK 5.17.x and 7.x header layouts supported.

Requirements

DependencyVersionNotes
OBS Studio30+libobs + obs-frontend-api
CMake3.16+Build system
Qt6.xCore + Network + Widgets
Zoom Meeting SDK5.17.x or 7.xPlace at third_party/zoom-sdk/. CMake detects flat (5.x) and subfolder (7.x) header layouts automatically.
C++ CompilerC++17MSVC 2022 / Clang 14+ / GCC 11+
Zoom Developer Accountโ€”Marketplace app with Public Client OAuth + PKCE, Meeting SDK / Embed enabled, and server-side broker secrets for Meeting SDK JWT minting.
Zoom bandwidth / app entitlementsโ€”Raw data is available through Meeting SDK apps. Standard accounts commonly have a 30 Mbps incoming video envelope; Enhanced Media / HBM can raise that to roughly 100 Mbps. Developers may need an app-level flag to test more than a small number of concurrent raw streams.
FFmpegโ€”Required at runtime for ISO recording. Also used when building with -DCOREVIDEO_HW_ACCEL=ON for hardware-accelerated I420โ†’NV12 conversion.
Qt TLS backend (Windows)โ€”Required for OAuth HTTPS. Ship Schannel TLS plugins to obs-plugins/64bit/plugins/tls/.

Architecture: System Overview

All Zoom SDK access lives exclusively in ZoomObsEngine. The plugin communicates with it through ZoomEngineClient โ€” a singleton that launches the engine process, owns the IPC channels, and distributes roster events and frame notifications to registered sources. The plugin contains no Zoom SDK headers or linkage.

Meeting joining is handled by the Zoom Control dock (ZoomDock) or the TCP/OSC APIs. On unexpected disconnects the ZoomReconnectManager automatically re-joins with exponential back-off. Sources each hold an AssignmentMode that controls which feed they subscribe to: a fixed participant, the active speaker, a spotlight slot, or the active screen share.

graph TB subgraph Machine["User's Machine"] subgraph OBS["OBS Studio"] Canvas["Canvas / Output"] VSrc["Zoom Participant\nSource"] Canvas --> VSrc end subgraph Plugin["obs-zoom-plugin (no SDK dependency)"] ZDock["ZoomDock\n(Control UI ยท CvStatusDot ยท CvBanner)"] ZEC["ZoomEngineClient\nโ˜… central singleton"] ZRM["ZoomReconnectManager\n(auto-reconnect)"] ZOAuth["ZoomOAuthManager\n(OAuth broker ยท SDK JWT fetch ยท DPAPI)"] ZS["ZoomSource\n(ShmRegion ยท AssignmentMode)"] ZOM["ZoomOutputManager"] ZCS["ZoomControlServer :19870"] ZOSC["ZoomOscServer :19871"] ZDock --> ZEC ZDock --> ZOAuth ZRM --> ZEC ZEC --> ZS ZCS & ZOSC --> ZOM & ZEC ZEC --> ZRM ZOAuth -->|"SDK JWT"| ZEC end subgraph Engine["ZoomObsEngine (ALL SDK access)"] ELoop["IPC Loop"] EVid["EngineVideo\n(SHM write)"] EAud["EngineAudio\n(SHM write)"] EPart["Participant & Spotlight\ntracking"] SDK["Zoom SDK\nAuth ยท Meeting ยท Webinar ยท Raw data"] ELoop --> EVid & EAud & EPart --> SDK end subgraph ZoomCloud["Zoom Cloud"] Mtg["Live Meeting / Webinar"] end subgraph External["External Control"] Ctrl["Script / Dashboard"] end end OBS -->|loads| Plugin VSrc <--> ZS Plugin <-->|"JSON IPC\n(named pipe / Unix socket)"| ELoop EVid & EAud -->|"Named Shared Memory\nI420 frames ยท PCM audio"| ZS SDK <-->|HTTPS/WSS| Mtg Ctrl <-->|"TCP :19870\nUDP OSC :19871"| ZCS & ZOSC style OBS fill:#0d2137,stroke:#2f81f7,color:#e6edf3 style Plugin fill:#1a2332,stroke:#388bfd,color:#e6edf3 style Engine fill:#1a2d1a,stroke:#3fb950,color:#e6edf3 style ZoomCloud fill:#2d1a2d,stroke:#bc8cff,color:#e6edf3 style External fill:#2d2000,stroke:#d29922,color:#e6edf3
Fig 1 - All SDK access is in ZoomObsEngine. ZoomOAuthManager handles broker-backed OAuth PKCE, token storage, and SDK JWT fetches. TCP and OSC provide external control without linking the Zoom SDK into OBS.

Architecture: Plugin Components

All Zoom SDK access lives in ZoomObsEngine. The plugin-side hierarchy centers on ZoomEngineClient (IPC singleton) and ZoomSource (reads frames from ShmRegion). New additions: ZoomDock (join UI), ZoomReconnectManager (auto-reconnect), and HwVideoPipeline (hardware I420โ†’NV12).

classDiagram class ZoomEngineClient { <<Singleton>> +start(jwt) / stop() / stop_for_reconnect() +join(id, pass, name, kind: MeetingKind) +leave() +subscribe(uuid, pid, isolate_audio) +subscribe_spotlight(uuid, slot) +subscribe_screenshare(uuid) +unsubscribe(uuid) +state() MeetingState +is_authenticated() bool +active_speaker_id() uint32_t +roster() vector~ParticipantInfo~ +register_source(uuid, callbacks) +add_roster_callback(key, cb) } class ZoomSource { +assignment : AssignmentMode +participant_id : atomic~uint32_t~ +spotlight_slot : atomic~uint32_t~ +failover_participant_id : atomic~uint32_t~ +resolution : VideoResolution +video_loss_mode : VideoLossMode +hw_accel_override : int +speaker_sensitivity_ms, speaker_hold_ms +configure_output_ex(mode, pid, slot, failover, โ€ฆ) +on_engine_frame(w, h) +on_engine_audio(byte_len) +m_hk_active_on_id, m_hk_active_off_id +m_hw_pipeline : HwVideoPipeline } class ZoomOAuthManager { <<Singleton>> +begin_authorization(parent) bool +handle_redirect_url(url) bool +register_url_scheme() bool +refresh_access_token_blocking() bool +fetch_sdk_jwt_blocking(jwt) bool -m_pending_state : QString -m_pending_verifier : QString } class ZoomDock { +on_join_clicked() +on_leave_clicked() +on_cancel_recovery_clicked() +update_state_indicator() +update_recovery_panel() +update_credentials_banner() -m_state_dot : CvStatusDot -m_credentials_banner : CvBanner -m_join_token_type : QComboBox -m_output_table : QTableWidget } class ZoomReconnectManager { <<Singleton>> +set_policy(policy) +store_session(jwt, id, pass, name) +trigger(reason: RecoveryReason) +cancel() +on_join_success() / on_join_failed(permanent) +is_recovering() bool +next_retry_ms() int } class HwVideoPipeline { +init(mode: HwAccelMode) bool +process(y,u,v, w,h, strides, frame) bool +active_mode() HwAccelMode +shutdown() } class ZoomOutputManager { <<Singleton>> +register_source / unregister_source +configure_output_ex(mode, โ€ฆ) +outputs() vector } class ZoomControlServer { <<Singleton>> +start(port=19870) +set_token(token) } class ZoomOscServer { <<Singleton>> +start(port=19871) } ZoomDock --> ZoomEngineClient : join / leave ZoomDock --> ZoomOAuthManager : begin_authorization ZoomOAuthManager ..> ZoomEngineClient : SDK JWT โ†’ engine init ZoomReconnectManager --> ZoomEngineClient : stop_for_reconnect / start / join ZoomEngineClient ..> ZoomReconnectManager : trigger / on_join_success ZoomSource --> ZoomEngineClient : subscribe / unsubscribe ZoomSource ..> HwVideoPipeline : I420โ†’NV12 conversion ZoomSource --> ZoomOutputManager ZoomControlServer --> ZoomOutputManager ZoomControlServer --> ZoomEngineClient ZoomOscServer --> ZoomOutputManager
Fig 2 โ€” Plugin class diagram. ZoomEngineClient is the IPC singleton; ZoomDock owns join flow; ZoomReconnectManager handles recovery.

Architecture: ZoomEngineClient

ZoomEngineClient is the plugin-side singleton that manages the entire engine process lifecycle. It launches ZoomObsEngine as a child process, connects the two IPC channels, runs a dedicated reader thread for incoming events, and dispatches per-source frame/audio callbacks. All Zoom SDK state (auth, meeting, roster, active speaker) is owned by the engine; the client tracks it locally by processing the event stream.

graph TB subgraph Plugin["obs-zoom-plugin process"] ZEC["ZoomEngineClient\nโ˜… singleton"] Reader["reader_loop()\n(dedicated thread)"] S1["ZoomSource A\n(ShmRegion video+audio)"] S2["ZoomSource B\n(ShmRegion video+audio)"] ZEC --> Reader ZEC -->|"on_frame / on_audio callbacks"| S1 ZEC -->|"on_frame / on_audio callbacks"| S2 end subgraph IPC["IPC"] P2E["Plugin โ†’ Engine\njson cmds"] E2P["Engine โ†’ Plugin\njson events"] SHM["Named Shared Memory\nZoomObsPlugin_<uuid>"] end subgraph Engine["ZoomObsEngine process"] SDK3["Zoom SDK\n(ALL SDK access)"] ETrack["Participant &\nSpeaker tracking"] SDK3 --- ETrack end ZEC -- "init ยท join ยท leave ยท subscribe ยท unsubscribe ยท quit" --> P2E --> Engine Engine -- "ready ยท auth_ok ยท joined ยท left ยท frame ยท audio\nparticipants ยท active_speaker ยท error" --> E2P --> Reader Engine -- "write frames" --> SHM S1 & S2 -- "mmap read" --> SHM style Plugin fill:#0d2137,stroke:#2f81f7,color:#e6edf3 style Engine fill:#1a2d1a,stroke:#3fb950,color:#e6edf3 style IPC fill:#2d1f00,stroke:#d29922,color:#e6edf3
Fig 3 โ€” ZoomEngineClient launches the engine, owns IPC channels, and dispatches callbacks to each ZoomSource.

Lifecycle

CallEffect
ZoomEngineClient::instance().start(jwt)Launches engine process, connects pipes/sockets (up to 300 retries ร— 100 ms), sends init with JWT
join(meeting_id, passcode, name)Sends join command; blocks until joined event or failure
subscribe(uuid, pid)Sends subscribe; engine begins writing frames to ZoomObsPlugin_<uuid> shared memory
unsubscribe(uuid)Stops frame delivery; ZoomSource releases its ShmRegion
leave() / stop()Sends leave / quit; reader thread joins

Roster, Active Speaker, and Spotlight

The engine sends a participants event whenever the roster changes and an active_speaker event when the speaking participant changes. ZoomEngineClient updates its internal roster and active-speaker ID from these events, then fires all registered RosterCallback functions. ZoomSource instances react based on their AssignmentMode: active-speaker sources run the debounce algorithm; spotlight sources send a subscribe_spotlight command when spotlight slots change.

Deferred Join (Pending Until Authenticated)

Calls to join() before the engine has authenticated store the meeting credentials in m_join_pending / m_pending_* fields. When the auth_ok event arrives, send_join_locked() is called automatically so operators can trigger join at any time.

Monitor Thread

A dedicated monitor_loop() thread watches for engine health issues (stalled reader, unexpected process exit) and sets the state to Failed, which triggers ZoomReconnectManager if recovery is configured.

Architecture: IPC Engine

ZoomObsEngine runs as a separate process on all platforms. Control messages use newline-delimited JSON over platform IPC channels. Frame data flows through named shared memory to avoid copying large buffers. ZoomEngineClient in the plugin abstracts all IPC details.

graph LR subgraph Plugin["obs-zoom-plugin"] ZEC2["ZoomEngineClient"] SHM_R["ZoomSource\nShmRegion (mmap read)"] end subgraph IPC["IPC Channels"] P2E["Plugin โ†’ Engine\nWindows: named pipe\nmacOS/Linux: Unix socket"] E2P["Engine โ†’ Plugin\nWindows: named pipe\nmacOS/Linux: Unix socket"] SHM["Named Shared Memory\nZoomObsPlugin_<uuid>"] end subgraph Engine["ZoomObsEngine"] ELoop["IPC Loop\n+ reader_loop"] EVid["EngineVideo"] EAud["EngineAudio"] SDK2["Zoom SDK\n(all SDK access)"] ELoop --> EVid & EAud --> SDK2 end ZEC2 -- "init ยท join ยท leave ยท subscribe ยท unsubscribe ยท quit" --> P2E --> ELoop ELoop -- "ready ยท auth_ok ยท auth_fail ยท joined ยท left ยท frame ยท audio\nparticipants ยท active_speaker ยท error" --> E2P --> ZEC2 EVid & EAud -- "write I420 + PCM" --> SHM --> SHM_R style Plugin fill:#0d2137,stroke:#2f81f7,color:#e6edf3 style Engine fill:#1a2d1a,stroke:#3fb950,color:#e6edf3 style IPC fill:#2d1f00,stroke:#d29922,color:#e6edf3
Fig 4 โ€” Cross-platform IPC: JSON over named pipes (Windows) or Unix sockets (macOS/Linux); frames through shared memory.
PlatformPlugin โ†’ EngineEngine โ†’ Plugin
Windows\\.\pipe\ZoomObsPlugin_P2E\\.\pipe\ZoomObsPlugin_E2P
macOS / Linux/tmp/ZoomObsPlugin_P2E.sock/tmp/ZoomObsPlugin_E2P.sock

Architecture: Video Pipeline

flowchart LR CAM["Participant\nCamera"] --> ZC["Zoom Cloud"] ZC -->|decoded I420| EV["Engine EngineVideo\n(IZoomSDKRenderer)"] EV -->|"write I420 to\nShmRegion"| SHM2["Shared Memory\nZoomObsPlugin_<uuid>"] SHM2 -->|"frame event\n{uuid, w, h}"| ZEC3["ZoomEngineClient\non_engine_frame cb"] ZEC3 --> ZS5["ZoomSource\non_engine_frame()"] ZS5 -->|"VideoLossMode:\nLastFrame / Black"| LOSS["Loss handling"] LOSS -->|"I420 planar\nPreviewCallback โ‰ค5fps"| OBS_V["OBS Video\nobs_source_output_video()"] style CAM fill:#2d1a2d,stroke:#bc8cff,color:#e6edf3 style ZC fill:#2d1a2d,stroke:#bc8cff,color:#e6edf3 style EV fill:#1a2d1a,stroke:#3fb950,color:#e6edf3 style SHM2 fill:#2d1f00,stroke:#d29922,color:#e6edf3 style ZEC3 fill:#0d2137,stroke:#2f81f7,color:#e6edf3 style ZS5 fill:#0d2137,stroke:#2f81f7,color:#e6edf3 style LOSS fill:#1a2332,stroke:#388bfd,color:#e6edf3 style OBS_V fill:#1a2137,stroke:#388bfd,color:#e6edf3
Fig 5 โ€” Video path: engine writes I420 to shared memory; ZoomSource reads via ShmRegion and forwards to OBS.
PropertyOptionsNotes
ResolutionP360 / P720 / P1080Sent to engine in subscribe command; passed to SDK renderer
Frame format in SHMI420 YUV planarWritten by engine directly; ZoomSource reads planes from ShmRegion
Output to OBSI420 planarVIDEO_FORMAT_I420 via obs_source_output_video()
Video loss โ€” LastFrameHold last decoded frameDefault OBS behaviour
Video loss โ€” BlackPush black I420 immediatelyUseful for clean cuts
Preview callbackโ‰ค 5 fps raw I420Used by OBS UI thumbnail / output dialog

Architecture: Audio Pipeline

Audio processing now lives entirely in the engine process. The engine writes PCM chunks to a per-source shared memory region and sends an audio event. ZoomSource reads the chunk from m_audio_shm and pushes it to OBS. Stereo sources use an internal m_stereo_buf to interleave channels before output.

flowchart TB SDK4["Zoom SDK\n(inside engine)"] EAud2["EngineAudio\n(mixed + per-user)"] SDK4 -->|raw PCM| EAud2 EAud2 -->|"write PCM to SHM\n+ send audio event"| SHM3["Shared Memory\nZoomObsPlugin_<uuid>"] SHM3 -->|"on_engine_audio(byte_len)"| ZS6["ZoomSource\n(m_audio_shm)"] ZS6 -->|"Mono: direct S16\nStereo: interleaved"| OBS_A["OBS Audio\nobs_source_output_audio()"] style SDK4 fill:#2d1a2d,stroke:#bc8cff,color:#e6edf3 style EAud2 fill:#1a2d1a,stroke:#3fb950,color:#e6edf3 style SHM3 fill:#2d1f00,stroke:#d29922,color:#e6edf3 style ZS6 fill:#0d2137,stroke:#2f81f7,color:#e6edf3
Fig 6 โ€” Audio pipeline: engine mixes/isolates audio in-process and delivers chunks through shared memory.
PropertyValue
Sample rate48 000 Hz
FormatS16LE
MonoMixed feed downmixed to one channel
StereoLeft/right separation via m_stereo_buf interleave in ZoomSource
Isolated audioConfigured per-source via isolate_audio flag in subscribe command

Architecture: Screen Share Pipeline

Screen share handling now lives entirely inside ZoomObsEngine. The engine's IMeetingShareCtrlEvent handler detects share-start events, attaches an IZoomSDKRenderer to the share source, and writes I420 frames to a dedicated shared memory region. The plugin receives frame events on the share source UUID and forwards them to OBS exactly like participant video.

flowchart LR SC2["Screen Share\nIn Meeting"] --> ZC3["Zoom Cloud"] ZC3 -->|onSharingStatus| EShare["Engine\nShareDelegate\n(inside ZoomObsEngine)"] EShare -->|subscribe share renderer| Rnd["IZoomSDKRenderer"] Rnd -->|"I420 frames"| SHM4["Shared Memory\nZoomObsPlugin_share"] SHM4 -->|"frame event"| ZEC4["ZoomEngineClient\nโ†’ ZoomSource (share)"] ZEC4 -->|obs_source_output_video| OBS_SS["OBS Zoom\nShare Source"] style SC2 fill:#2d1a2d,stroke:#bc8cff,color:#e6edf3 style ZC3 fill:#2d1a2d,stroke:#bc8cff,color:#e6edf3 style EShare fill:#1a2d1a,stroke:#3fb950,color:#e6edf3 style SHM4 fill:#2d1f00,stroke:#d29922,color:#e6edf3 style ZEC4 fill:#0d2137,stroke:#2f81f7,color:#e6edf3 style OBS_SS fill:#1a2137,stroke:#388bfd,color:#e6edf3
Fig 7 โ€” Screen share: engine-side delegate writes I420 to shared memory; plugin receives via ZoomEngineClient frame callback.

Flow: Authentication

Published CoreVideo builds do not ship Meeting SDK secrets in the OBS plugin. After Zoom sign-in, the plugin asks the CoreVideo broker for a short-lived Meeting SDK JWT. The broker validates the signed-in Zoom access token and signs the SDK JWT server-side. The engine is not started on module load; it is launched the first time a join is requested.

sequenceDiagram participant OBS as OBS Studio participant PM as plugin-main participant ZDock as ZoomDock participant ZEC5 as ZoomEngineClient participant ZOA2 as ZoomOAuthManager participant Broker as CoreVideo Broker participant Engine as ZoomObsEngine participant SDK as Zoom SDK OBS->>PM: obs_module_load() PM->>ZOA2: load OAuth tokens and broker URL PM->>ZoomControlServer: start(control_server_port) PM->>ZoomOscServer: start(osc_server_port) PM->>ZDock: ensure_zoom_dock() [after OBS_FRONTEND_EVENT_FINISHED_LOADING] Note over ZDock: User clicks Join or API sends join cmd ZDock->>ZOA2: fetch_sdk_jwt_blocking() ZOA2->>Broker: POST /oauth/sdk-jwt with OAuth access token Broker-->>ZOA2: short-lived Meeting SDK JWT ZDock->>ZEC5: start(sdk_jwt) ZEC5->>Engine: launch_engine() [fork / CreateProcess] ZEC5->>ZEC5: connect_ipc() [300 x 100ms retries] Engine-->>ZEC5: {"cmd":"ready"} ZEC5->>Engine: {"cmd":"init","jwt":"โ€ฆ"} Engine->>SDK: InitSDK() + SDKAuth({jwt}) SDK-->>Engine: onAuthenticationReturn(SUCCESS) Engine-->>ZEC5: {"cmd":"auth_ok"} Note over ZEC5: authenticated true, send pending join if queued opt Identity expires later SDK-->>Engine: onZoomIdentityExpired() Engine->>SDK: SDKAuth(last_jwt) Engine-->>ZEC5: {"cmd":"auth_ok"} end
Fig 8 - SDK JWT is fetched from the broker after OAuth token validation. Engine is launched on first join. Auth expiry is handled automatically inside the engine.

Flow: Zoom OAuth PKCE

ZoomOAuthManager implements broker-backed OAuth 2.0 with PKCE (Proof Key for Code Exchange, S256) for user-level attributed meeting joins and Zoom App Marketplace compliance. The desktop plugin opens the broker start URL, receives a short-lived broker token through corevideo://oauth/callback, and never stores a Zoom app secret.

Setup (one-time per machine)

Use the embedded broker

Published builds already contain the CoreVideo broker URL. Open Tools โ†’ Zoom Plugin Settings; there are no Client ID, Client Secret, or Authorization URL fields for end users to configure.

Register the URL scheme

Click Sign in with Zoom. CoreVideo registers the corevideo:// callback helper automatically on first use.

Authorize

CoreVideo opens the browser at https://corevideo.iamfatness.us/oauth/start. Approve the app in Zoom.

Callback forwarding

Zoom redirects to the broker callback, then the broker returns an encrypted broker token to corevideo://oauth/callback. The helper forwards that URL to the plugin via the TCP control server (oauth_callback command). Tokens are stored; on Windows they are DPAPI-protected before writing to OBS config.

Authorization Flow

sequenceDiagram participant User participant ZDock3 as ZoomDock participant ZOA as ZoomOAuthManager participant Browser participant Broker2 as CoreVideo Broker participant ZoomCloud2 as Zoom Cloud participant Helper as CoreVideoOAuthCallback participant ZCS2 as ZoomControlServer User->>ZDock3: click "Sign in with Zoom" ZDock3->>ZOA: begin_authorization() ZOA->>ZOA: random_base64url(16) โ†’ state ZOA->>Browser: QDesktopServices::openUrl(/oauth/start?state=โ€ฆ) Browser->>Broker2: GET /oauth/start Broker2->>Broker2: generate PKCE verifier/challenge Broker2->>ZoomCloud2: redirect to /v2/authorize?client_id=โ€ฆ&code_challenge=โ€ฆ ZoomCloud2-->>Browser: consent screen User->>Browser: approve ZoomCloud2-->>Broker2: redirect to /oauth/callback?code=โ€ฆ&state=โ€ฆ Broker2-->>Browser: redirect to corevideo://oauth/callback?broker_token=โ€ฆ&state=โ€ฆ Browser->>Helper: launch CoreVideoOAuthCallback (Windows: .exe / macOS: .app) Helper->>ZCS2: POST {"cmd":"oauth_callback","url":"corevideo://oauth/callback?broker_token=โ€ฆ&state=โ€ฆ"} ZCS2->>ZOA: handle_redirect_url(url) ZOA->>ZOA: verify state matches m_pending_state ZOA->>Broker2: POST /oauth/redeem broker_token=โ€ฆ Broker2->>ZoomCloud2: POST /oauth/token client_id + code_verifier, no secret Broker2-->>ZOA: {access_token, refresh_token, expires_in} ZOA->>ZOA: store tokens (DPAPI on Windows) Note over ZOA: Ready for SDK JWT fetch on next join
Fig 8b - OAuth PKCE flow: browser opens broker start URL, broker performs Zoom PKCE exchange, helper binary forwards broker token, plugin stores returned tokens.

Meeting SDK JWT Fetch (before each join)

sequenceDiagram participant ZDock4 as ZoomDock participant ZOA2 as ZoomOAuthManager participant Broker3 as CoreVideo Broker participant ZoomAPI as Zoom REST API participant ZEC8 as ZoomEngineClient ZDock4->>ZOA2: fetch_sdk_jwt_blocking(jwt) ZOA2->>ZOA2: check token expiry\nrefresh_access_token_blocking() if expired ZOA2->>Broker3: POST /oauth/sdk-jwt\nAuthorization: Bearer {access_token} Broker3->>ZoomAPI: GET /v2/users/me\nAuthorization: Bearer {access_token} ZoomAPI-->>Broker3: signed-in user profile Broker3-->>ZOA2: {"sdk_jwt": "โ€ฆ"} ZOA2-->>ZDock4: sdk_jwt string ZDock4->>ZEC8: start(sdk_jwt), then join(id, pass, name, kind) ZEC8->>Engine: {"cmd":"init","jwt":"โ€ฆ"} then {"cmd":"join","meeting_id":"โ€ฆ"}
Fig 8c - A Meeting SDK JWT is fetched fresh before each join using the stored OAuth access token; token refresh is automatic when expired.

Security Notes

PropertyImplementation
PKCE methodS256 - SHA-256 of a 32-byte random verifier, base64url-encoded by the broker
State parameter16-byte random base64url value; verified on callback to prevent CSRF
Client secretNo OAuth or Meeting SDK client secret is shipped in the OBS plugin. Meeting SDK secrets live only as Cloudflare Worker secrets used to mint short-lived SDK JWTs.
Token storage (Windows)Access and refresh tokens encrypted with DPAPI before writing to OBS global config
No secret loggingAccess tokens, refresh tokens, SDK JWTs, codes, broker tokens, and verifiers are never written to logs or IPC messages
Refresh rotationAlways persist the latest refresh token Zoom returns; stale tokens are discarded
Callback token bypassThe oauth_callback TCP command bypasses the control server token; the OAuth state and one-time verifier still guard against replay
Full setup guide
See docs/ZOOM_MARKETPLACE_OAUTH.md for the complete Zoom Marketplace app configuration, local setup walkthrough, and Marketplace review checklist.

Flow: Meeting Join & Capture

Joining is centralized in ZoomDock (or the TCP/OSC control APIs) โ€” not in individual sources. Sources subscribe to the already-joined meeting.

sequenceDiagram participant U as User / Control API participant ZDock2 as ZoomDock participant ZRM2 as ZoomReconnectManager participant ZEC6 as ZoomEngineClient participant ZS3 as ZoomSource participant Engine as ZoomObsEngine U->>ZDock2: click Join (meeting_id, passcode, display_name, [webinar]) ZDock2->>ZRM2: store_session(jwt, id, pass, name) ZDock2->>ZEC6: start(jwt) [launches engine if not running] ZDock2->>ZEC6: join(id, pass, name, kind=Meeting|Webinar) ZEC6->>Engine: {"cmd":"join","meeting_id":"โ€ฆ","kind":"meeting|webinar"} Engine-->>ZEC6: {"cmd":"joined"} ZEC6-->>ZRM2: on_join_success() Note over ZS3: ZoomSource.activate() when OBS activates source ZS3->>ZEC6: subscribe(uuid, pid, isolate) OR subscribe_spotlight(uuid, slot) ZEC6->>Engine: {"cmd":"subscribe","source_uuid":"โ€ฆ","participant_id":N} loop Live capture Engine-->>ZEC6: {"cmd":"frame","uuid":"โ€ฆ","w":W,"h":H} ZEC6-->>ZS3: on_engine_frame(w, h) ZS3->>ZS3: read I420 from video shm, process with HwVideoPipeline if enabled ZS3->>OBS: obs_source_output_video() Engine-->>ZEC6: {"cmd":"audio","uuid":"โ€ฆ","byte_len":B} ZEC6-->>ZS3: on_engine_audio(byte_len) ZS3->>OBS: obs_source_output_audio() end opt Engine crash / unexpected disconnect ZEC6-->>ZRM2: trigger(EngineCrash | MeetingDisconnect) ZRM2->>ZRM2: schedule retry with exponential back-off ZRM2->>ZEC6: stop_for_reconnect() [on retry] ZRM2->>ZEC6: start(jwt) + join(โ€ฆ) end U->>ZDock2: click Leave ZDock2->>ZEC6: leave() ZEC6->>Engine: {"cmd":"leave"} ZRM2->>ZRM2: clear_session() [no recovery after explicit leave]
Fig 9 โ€” Join is driven by ZoomDock. Sources subscribe once in meeting. ZoomReconnectManager handles unexpected disconnects.

Assignment Modes

Each ZoomSource has an AssignmentMode property that controls which feed it subscribes to. This is the ZoomISO 3.0 model: sources are reusable roles rather than fixed participant bindings.

ModeEnum valueBehaviour
ParticipantAssignmentMode::ParticipantSubscribe to a fixed participant_id. If a failover_participant_id is set and the primary participant leaves, the source automatically re-subscribes to the failover.
Active SpeakerAssignmentMode::ActiveSpeakerFollow whoever is currently speaking. Uses the two-timer debounce (sensitivity + hold). See Active Speaker Mode section for details.
Spotlight SlotAssignmentMode::SpotlightIndexSubscribe to Spotlight slot N (1-based). The engine tracks which participant is in each spotlight position and sends subscribe_spotlight subscriptions automatically when the slot changes.
Screen ShareAssignmentMode::ScreenShareSubscribe to the active screen-share feed. Uses subscribe_screenshare(uuid) on the engine. The source shows a placeholder when no share is active.

Failover Participant

In Participant mode, a failover_participant_id (0 = disabled) can be configured alongside the primary participant. When the engine sends a participants event showing the primary ID has left, ZoomSource automatically re-subscribes to the failover ID. When the primary rejoins, it switches back.

Spotlight Tracking

In Spotlight Slot mode, the roster callback checks ParticipantInfo::spotlight_index on every roster update. The source sends a subscribe_spotlight(uuid, slot) to the engine, which resolves the participant in that slot and sets up the SDK renderer. This mirrors the ZoomISO "Spotlight 1/2/3" output model.

flowchart LR subgraph Source["ZoomSource (AssignmentMode)"] P["Participant\nfixed pid + failover"] AS["ActiveSpeaker\ndebounce + liveness"] SP["SpotlightIndex\nslot 1โ€ฆN"] SS["ScreenShare\nactive share feed"] end P -->|subscribe fixed participant| ZEC7["ZoomEngineClient"] AS -->|subscribe active speaker| ZEC7 SP -->|subscribe spotlight slot| ZEC7 SS -->|subscribe screen share| ZEC7 ZEC7 -->|JSON IPC| Engine2["ZoomObsEngine\n(resolves โ†’ SDK renderer)"] style Source fill:#0d2137,stroke:#2f81f7,color:#e6edf3 style ZEC7 fill:#1a2332,stroke:#388bfd,color:#e6edf3 style Engine2 fill:#1a2d1a,stroke:#3fb950,color:#e6edf3
Assignment modes map to different ZoomEngineClient subscribe calls; the engine resolves each to the correct SDK renderer.

Active Speaker Mode

When Follow active speaker is enabled on a Zoom Participant source, the plugin continuously monitors who is speaking and switches the video subscription (and optionally the isolated audio feed) to the new speaker. A two-timer debounce prevents rapid camera cuts caused by brief interruptions.

The current build also includes a central Active Speaker Director in the Zoom Control dock and a dedicated CoreVideo Active Speaker OBS source. The director tracks the raw Zoom speaker, candidate speaker, directed speaker, last directed speaker, and manual take/release state. The dedicated source follows the directed speaker and uses a two-slot handoff: the current participant stays visible while the next participant warms on a hidden slot, then the source cuts only after a valid frame is available.

Debounce Algorithm

Every time ZoomEngineClient dispatches a roster callback (triggered by an active_speaker or participants IPC event), the source runs on_active_speaker_changed(). Two independent timers must both be satisfied before a switch is committed:

The actual delay is max(hold_remain, sense_remain). If the delay is zero the switch fires immediately on the calling thread; otherwise a detached background thread sleeps for the delay and then posts a UI task via obs_queue_task(OBS_TASK_UI, โ€ฆ) to re-evaluate on the OBS UI thread.

stateDiagram-v2 [*] --> Idle : active_speaker_mode = off Idle --> Idle : speaker change\n(mode is off, ignored) state "Active Speaker Mode ON" as ASM { [*] --> Watching Watching --> Evaluating : ZoomEngineClient fires roster cb\nnew active_speaker_id\n(โ‰  current participant) Evaluating --> Switching : delay = max(hold_remain, sense_remain) == 0\nAND speaker still active Evaluating --> Scheduled : delay > 0\nโ†’ schedule_speaker_check(spk, delay) Scheduled --> Evaluating : timer fires\nโ†’ try_commit_speaker() on UI thread Scheduled --> Watching : newer candidate\nsupersedes pending Switching --> Watching : do_speaker_switch()\nโ€ข unsubscribe old video\nโ€ข subscribe(new_pid, resolution)\nโ€ข update isolated audio user\nโ€ข record last_switch_time Evaluating --> Watching : speaker changed again\nbefore commit\n(pending_speaker_id reset) } Idle --> ASM : active_speaker_mode = on ASM --> Idle : active_speaker_mode = off\nor source destroyed
Fig 10 โ€” Active speaker state machine: debounce evaluation, deferred commit, and supersede handling.

Switch Sequence

sequenceDiagram participant ZP as ZoomEngineClient\n(roster_callback) participant ZS4 as ZoomSource participant Thread as Background Thread participant UI as OBS UI Thread participant VD as VideoDelegate ZP-->>ZS4: roster callback\n(active_speaker_id from engine event = X) ZS4->>ZS4: on_active_speaker_changed()\npending = X, candidate_since = now alt delay == 0 ZS4->>ZS4: do_speaker_switch(X) ZS4->>VD: unsubscribe() ZS4->>VD: subscribe(X, resolution) Note over ZS4: isolate_audio โ†’ set_isolated_user(X) Note over ZS4: last_switch_time = now else delay > 0 ZS4->>Thread: schedule_speaker_check(X, delay_ms) Thread->>Thread: sleep(delay_ms) Thread->>UI: obs_queue_task(try_commit_speaker, X) UI->>ZS4: try_commit_speaker(X) alt X == pending_speaker_id AND still active ZS4->>ZS4: do_speaker_switch(X) ZS4->>VD: unsubscribe() + subscribe(X, resolution) else superseded or speaker moved on ZS4->>ZS4: pending_speaker_id = 0\n(or re-schedule) end end
Fig 11 โ€” Active speaker switch sequence: immediate vs. deferred commit with UI-thread safety.

Timing Parameters

ParameterDefaultRangeDescription
speaker_sensitivity_ms500 ms0 โ€“ 3 000 ms (step 50)New speaker must hold the floor this long before the switch fires
speaker_hold_ms2 000 ms0 โ€“ 10 000 ms (step 100)Minimum time to stay on current speaker after any switch

The Zoom Control dock exposes these values through the Active Speaker Director controls. Operators can also manually take a participant to air and release that supersede when automatic direction should resume.

Safety Mechanisms

Audio Isolation Interaction

When Isolate Audio is also enabled, every speaker switch triggers a new subscribe command to the engine with the updated participant ID and the isolate_audio flag, so the engine adjusts the audio feed to follow the same participant as the video.

UI Behaviour

Enabling active speaker mode in the Properties panel automatically disables the Participant dropdown (not relevant while following the speaker) and enables the Sensitivity and Hold sliders. The participant list still shows live roster entries with talking (โ—) and video ([video]) indicators, and updates when the Refresh button is pressed.

Director TCP Controls

CommandRequestUse
speaker_director_status{"cmd":"speaker_director_status"}Returns directed, raw, candidate, last, manual, sensitivity, and hold state
speaker_director_configure{"cmd":"speaker_director_configure","sensitivity_ms":650,"hold_ms":2500}Updates director timing
speaker_director_take{"cmd":"speaker_director_take","participant_id":123}Manually holds a participant as the directed speaker
speaker_director_release{"cmd":"speaker_director_release"}Returns to automatic speaker direction

Auto-Reconnect

ZoomReconnectManager automatically re-joins the meeting after engine crashes, network drops, SDK errors, or host-ended meetings. It is configurable per-session and shows a live countdown in the Zoom Control dock.

Reconnect Policy

FieldDefaultDescription
enabledtrueMaster switch for auto-reconnect
max_attempts5Maximum retry attempts before giving up
base_delay_ms2 000 msInitial delay before first retry
max_delay_ms30 000 msMaximum delay between retries (caps the back-off)
backoff_multiplier2.0Exponential multiplier: delay ร— 2^attempt
on_engine_crashtrueTrigger recovery on engine process exit
on_disconnecttrueTrigger recovery on unexpected meeting disconnect
on_auth_failfalseTrigger recovery on auth failure (disabled by default)

Recovery Triggers

RecoveryReasonCause
EngineCrashEngine process exited unexpectedly
MeetingDisconnectSDK reported unexpected meeting end
NetworkDropNetwork connectivity lost mid-meeting
AuthFailureSDK authentication failed or expired without self-healing
SdkErrorFatal SDK error returned to engine
HostEndedMeetingHost ended the meeting (re-join if meeting resumes)
LicenseErrorRaw-data permission, app approval, or developer entitlement issue

Cancellation Safety

A monotonically increasing generation counter ensures stale timer wakeups never fire a retry that was already cancelled or superseded. Calling cancel() (via the dock or API) bumps the generation; the timer thread sees the mismatch and discards the pending retry.

Explicit leave() calls set the m_user_leaving flag and call clear_session(), preventing any recovery attempt after a deliberate disconnect.

Zoom Control Dock

ZoomDock is a dockable Qt widget added to OBS on plugin load. Access it via Tools โ†’ Zoom Control or drag it to any dock position in OBS. It is the primary UI for meeting management.

CoreVideo OBS workspace with Zoom Control dock
CoreVideo runs as normal OBS UI: the Zoom Control dock manages meeting state and raw media while Zoom Participant sources render inside OBS scenes.

Meeting Control Bar

ControlDescription
CvStatusDot + labelAnimated QPainter status indicator โ€” pulses during transitional states (Joining / Leaving / Recovering); static dot for Idle / InMeeting / Failed
CvBanner (first-run)Dismissable notice strip shown until SDK credentials are configured
Meeting ID fieldZoom meeting number; persisted from last session
Passcode fieldOptional meeting passcode
Display Name fieldName shown inside the Zoom meeting; persisted from last session
Token type selectorCombo box to choose join auth method. Published builds use Auto Zoom sign-in, which fetches a broker-minted Meeting SDK JWT before joining.
Webinar checkboxUse Zoom Webinar SDK join API instead of regular meeting API; persisted
Join buttonFetches a broker-minted Meeting SDK JWT when needed, starts ZoomObsEngine, then calls ZoomEngineClient::join()
Leave buttonCalls ZoomEngineClient::leave() and clears reconnect session
Start Engine buttonStarts raw media capture after the meeting is joined and sends Zoom video/audio to OBS outputs
Stop Engine buttonStops raw media capture while staying joined to the meeting
Participant filterFilter participant list by display name
Participant listShows Zoom display name, user ID, video/audio state, talking state, spotlight, and screen-share tags; participants can be dragged onto output rows
Active speaker labelShows current active speaker's display name

Session Persistence

The dock saves last_meeting_id, last_display_name, and last_was_webinar to the plugin settings file on each successful join. These values are restored and pre-filled in the dock on next OBS launch.

Recovery Panel

Shown automatically when MeetingState::Recovering. Displays:

Routing Section

The dock no longer embeds the full output table. Its routing section opens the dedicated Zoom Output Manager dock, which is the primary assignment surface for saving, loading, deleting profiles, and reviewing requested resolution, observed signal, frame rate, audio mode, and isolated-audio state.

Hardware Video Acceleration

When built with -DCOREVIDEO_HW_ACCEL=ON, each ZoomSource owns a HwVideoPipeline that converts incoming I420 frames to NV12 using FFmpeg hardware acceleration. This offloads color-space conversion from the CPU to the GPU/media engine.

HwAccelModeBackendPlatform
NoneCPU path (default)All
AutoFirst available hardware backendAll
CudaNVIDIA CUDAWindows / Linux
VaapiVAAPI (Intel / AMD)Linux
VideoToolboxApple VideoToolboxmacOS
QsvIntel Quick SyncWindows / Linux

A per-source hw_accel_override property (โˆ’1 = use global setting) allows different acceleration modes per source. HwVideoPipeline builds a dynamic FFmpeg filter graph on the first frame and rebuilds it automatically on resolution change. On any error it falls back to the CPU path and sets a m_broken flag to avoid repeated failures.

Build requirement
Hardware acceleration requires FFmpeg (libavfilter, libavhwaccel). Without -DCOREVIDEO_HW_ACCEL=ON, HwVideoPipeline is compiled to an empty stub and init() always returns false.

TCP Control API

Listens on 127.0.0.1:19870 (configurable). Each request and response is a single-line compact JSON terminated by \n. If a token is configured, every request must include "token":"<value>". Token comparison uses constant-time equality to prevent timing attacks.

Quick test
echo '{"cmd":"status"}' | nc 127.0.0.1 19870
CommandRequestResponse fields
help{"cmd":"help"}commands โ€” array of names
status{"cmd":"status"}meeting_state, active_speaker_id
list_participants{"cmd":"list_participants"}participants array
list_outputs{"cmd":"list_outputs"}outputs array with requested signal, observed signal, subscribed age, stale state, recovery attempts, quality-upgrade attempts, and retry cooldowns
recover_stale_outputs{"cmd":"recover_stale_outputs","force":true}recovered count after retrying stale feeds
upgrade_low_quality_outputs{"cmd":"upgrade_low_quality_outputs","force":true}upgraded count after retrying live feeds below requested resolution; skips feeds already at 1080p
assign_output{"cmd":"assign_output","source":"โ€ฆ","participant_id":N,"active_speaker":false,"isolate_audio":true,"audio_channels":"stereo"}ok, error
speaker_director_status{"cmd":"speaker_director_status"}speaker_director object with directed/raw/candidate/manual/timing state
speaker_director_configure{"cmd":"speaker_director_configure","sensitivity_ms":650,"hold_ms":2500}speaker_director object after applying timing changes
speaker_director_take{"cmd":"speaker_director_take","participant_id":N}speaker_director object with manual supersede enabled
speaker_director_release{"cmd":"speaker_director_release"}speaker_director object with automatic direction restored
join{"cmd":"join","meeting_id":"โ€ฆ","passcode":"โ€ฆ","display_name":"OBS"}ok
leave{"cmd":"leave"}ok
oauth_callback{"cmd":"oauth_callback","url":"corevideo://oauth/callback?broker_token=โ€ฆ&state=โ€ฆ"}ok or error - forwards the OS URL-scheme callback to ZoomOAuthManager::handle_redirect_url(). Token check is bypassed for this command; OAuth state and broker token validation still guard against replay.

Participant object

{ "id": 123, "name": "Alice", "has_video": true, "is_talking": false, "is_muted": false }

Output object

{ "source": "Zoom Participant 1", "participant_id": 123, "active_speaker": false,
  "assignment_mode": "participant", "video_resolution": "1080p",
  "observed_width": 1920, "observed_height": 1080, "observed_fps": 29.97,
  "isolate_audio": true, "audio_channels": "stereo" }

Meeting states

StateMeaning
idleNot in a meeting
joiningJoin in progress
in_meetingActive meeting
leavingLeave in progress
failedMeeting connection failed

OSC Control API

Listens on 127.0.0.1:19871 UDP (configurable). Accepts standard OSC 1.0 datagrams. Replies are sent back to the originating host/port. Supported argument types: i (int32), f (float32), s (string), T / F (true/false booleans).

Incoming Addresses

AddressType tagsArgumentsAction
/zoom/statusโ€”โ€”Reply with meeting state + active speaker
/zoom/list_outputsโ€”โ€”Reply one /zoom/output packet per output
/zoom/recover_stale_outputs[,i]optional force flagRetry stale video outputs and reply with recovered count
/zoom/upgrade_low_quality_outputs[,i]optional force flagRetry live video outputs below requested resolution and reply with upgraded count
/zoom/list_participantsโ€”โ€”Reply one /zoom/participant packet per participant
/zoom/join,sssmeeting_id, passcode, display_nameJoin meeting
/zoom/leaveโ€”โ€”Leave meeting
/zoom/assign_output,si[i]source, participant_id, [active_speaker 0/1]Assign source to participant
/zoom/assign_output/active_speaker,ssourceSwitch source to active-speaker mode
/zoom/isolate_audio,sisource, 0|1Toggle audio isolation for a source

Reply Addresses (sent by plugin)

AddressType tagsFields
/zoom/status/meeting_state,sstate string
/zoom/status/active_speaker,iuser_id
/zoom/output,sisiisource_name, participant_id, display_name, active_speaker, isolate_audio
/zoom/participant,isiiiuser_id, display_name, has_video, is_talking, is_muted

Output Profiles

The ZoomOutputProfile namespace persists named output configurations as JSON files under the OBS plugin config directory:

obs-studio/plugin_config/obs-zoom-plugin/profiles/<name>.json

Each profile stores all current output assignments (source name, participant ID, active speaker flag, audio isolation, audio mode). Use the Zoom Output Manager dock (or OBS โ†’ Tools to focus it) to save, load, and delete profiles interactively, or call the ZoomOutputProfile API directly from code.

FunctionDescription
ZoomOutputProfile::list()Returns names of all saved profiles
ZoomOutputProfile::save(name, outputs)Writes profile JSON file
ZoomOutputProfile::load(name)Reads profile and returns output configs
ZoomOutputProfile::remove(name)Deletes profile JSON file
CoreVideo Output Manager with participant and output mapping
The Output Manager mirrors the dock assignment controls and remains useful for named profile save/load workflows.

Profile JSON format

[
  {
    "source": "Zoom Participant 1",
    "display_name": "Camera A",
    "participant_id": 123,
    "active_speaker": false,
    "isolate_audio": true,
    "audio_channels": "stereo"
  }
]

IPC Protocol Reference

All messages are UTF-8 JSON terminated by \n. Frame data is transferred through named shared memory (prefix ZoomObsPlugin_).

Plugin โ†’ Engine

CommandPayloadDescription
init{"cmd":"init","jwt":"โ€ฆ"}Initialize SDK and authenticate with the broker-minted Meeting SDK JWT
join{"cmd":"join","meeting_id":"โ€ฆ","passcode":"โ€ฆ","display_name":"OBS","kind":"meeting|webinar","user_zak":"โ€ฆ","on_behalf_token":"โ€ฆ"}Join meeting or webinar. Published broker-backed joins authenticate the SDK during init; user_zak and on_behalf_token remain developer/manual fallback fields.
leave{"cmd":"leave"}Leave meeting
subscribe{"cmd":"subscribe","source_uuid":"โ€ฆ","participant_id":N,"isolate_audio":bool}Subscribe to fixed participant frames
subscribe_spotlight{"cmd":"subscribe_spotlight","source_uuid":"โ€ฆ","slot":N}Subscribe to spotlight slot N (1-based)
subscribe_screenshare{"cmd":"subscribe_screenshare","source_uuid":"โ€ฆ"}Subscribe to active screen-share feed
unsubscribe{"cmd":"unsubscribe","source_uuid":"โ€ฆ"}Stop frame delivery for this source
quit{"cmd":"quit"}Shut down engine process

Engine โ†’ Plugin

EventPayloadDescription
ready{"cmd":"ready"}Engine started and IPC connected
auth_ok{"cmd":"auth_ok"}SDK authenticated; pending join fires now
auth_fail{"cmd":"auth_fail","code":N}Authentication failed
joined{"cmd":"joined"}Now in meeting
left{"cmd":"left"}Left meeting (normal or host-ended)
frame{"cmd":"frame","uuid":"โ€ฆ","shm":"โ€ฆ","w":W,"h":H}Video frame written to named shared memory
audio{"cmd":"audio","uuid":"โ€ฆ","shm":"โ€ฆ","byte_len":B}PCM audio chunk written to named shared memory
participants{"cmd":"participants","active_speaker_id":N,"participants":[{"user_id":N,"display_name":"โ€ฆ","has_video":bool,"is_talking":bool,"is_muted":bool,"is_host":bool,"is_co_host":bool,"raised_hand":bool,"spotlight_index":N,"is_sharing_screen":bool},โ€ฆ]}Full roster snapshot on any roster change
active_speaker{"cmd":"active_speaker","participant_id":N}Active speaking participant changed
error{"cmd":"error","msg":"โ€ฆ","code":N}SDK or meeting error

Installation

โ„น๏ธ Pre-built releases
Download the latest Windows package from CoreVideo v0.1.18. This release includes dockable Zoom Diagnostics and Zoom Output Manager panels, the OBS plugin, ZoomObsEngine, the OAuth callback helper, and the required Zoom SDK runtime DLLs.

Get the Zoom SDK

Download from the Zoom Developer Portal and place it at third_party/zoom-sdk/. CMake auto-detects x64 / arm64 / x86 sub-layouts on Windows.

Configure CMake

cmake -B build \
  -DCMAKE_BUILD_TYPE=Release \
  -DZOOM_SDK_DIR=third_party/zoom-sdk \
  -DCMAKE_PREFIX_PATH="/path/to/obs-studio;/path/to/Qt6"

Build

cmake --build build --config Release

Builds obs-zoom-plugin and ZoomObsEngine. On Windows, sdk.dll is copied automatically if found.

Install into OBS

cmake --install build --prefix "/path/to/obs-studio"

Configure and launch

Open OBS โ†’ Tools โ†’ Zoom Plugin Settings, sign in with Zoom, and configure local control-server/reconnect settings. Published builds already know the CoreVideo broker URL; end users do not enter Zoom app credentials.

Configuration

Settings Dialog (Tools โ†’ Zoom Plugin Settings)

SDK Credentials

FieldDefaultDescription
SDK KeyEmbedded/brokeredDeveloper-only override for local builds. Published builds use broker-minted Meeting SDK JWTs.
SDK SecretServer-side onlyNot entered by end users and not shipped in the plugin. The Cloudflare broker stores this as a secret.
JWT Tokenโ€”Optional developer override; leave blank for normal broker-backed joins.

OAuth Settings

FieldDefaultDescription
OAuth Client IDEmbedded/brokeredDeveloper-only field when the build has no embedded broker URL. Published builds use the broker public client configuration.
OAuth Client SecretNot used in published builds. Public Client OAuth uses PKCE and no desktop secret.
Authorization URLEmbeddedPublished builds use https://corevideo.iamfatness.us/oauth/start.
Redirect URIcorevideo://oauth/callbackLocal return URI used by the callback helper after broker authorization.
Scopesuser:read:token user:read:userOAuth scopes requested during authorization.
OAuth Status labelโ€”Shows connected/disconnected status and token expiry
Register corevideo:// URL Schemeโ€”Registers the OS URL scheme handler for the callback helper; published builds do this automatically on sign-in.
Sign in with Zoomโ€”Opens browser to begin the broker-backed PKCE authorization flow.
Refresh Tokenโ€”Manually trigger token refresh
Disconnectโ€”Clears stored access and refresh tokens

Control & Network

FieldDefaultDescription
Control Server Port19870TCP JSON API port
Control Server Token(empty)Optional auth token; constant-time comparison
OSC Server Port19871UDP OSC API port

Meeting Control Dock

FieldDefaultDescription
Meeting ID or Zoom URLlast usedNumeric meeting ID or full Zoom join URL
Passcodeโ€”Optional meeting password; auto-filled from join URLs when present
Display NameOBSName shown inside the Zoom meeting
Join as Webinar / Zoom EventsoffUses the webinar join path when selected

Zoom Participant Source Properties

CoreVideo Zoom Participant source properties
Each Zoom Participant source keeps its own assignment mode, audio routing, requested resolution, and failover behavior.
PropertyDefaultDescription
Output Labelโ€”Label shown in the OBS Output Manager
Participantโ€”Select from live roster
Follow active speakeroffAutomatically follow whoever is speaking
Switch sensitivity (ms)300New speaker must hold the floor this long before switching
Minimum hold time (ms)2000Stay on current speaker at least this long after switching
Video Resolution1080p360p / 720p / 1080p
On video lossHold last frameHold last frame or show black
Audio ChannelsMonoNone / Mono / Stereo
Isolate AudiooffUse per-user feed instead of meeting mix

Zoom Participant Audio Source

PropertyDescription
ParticipantSelect from live roster
Audio ChannelsMono / Stereo

Zoom Interpretation Audio Source

PropertyDescription
LanguageLanguage name as reported by Zoom (e.g. English, Spanish)
โš ๏ธ Display Name
The plugin joins meetings as the configured display name (default "OBS"). Ensure the host allows guests or pre-admits the OBS participant from the waiting room.

Building from Source

Directory Layout

CoreVideo/
โ”œโ”€โ”€ CMakeLists.txt
โ”œโ”€โ”€ buildspec/
โ”‚   โ”œโ”€โ”€ macos.cmake
โ”‚   โ””โ”€โ”€ windows.cmake
โ”œโ”€โ”€ cmake/
โ”‚   โ””โ”€โ”€ CoreVideoOAuthCallback-Info.plist.in  # macOS OAuth helper bundle plist
โ”œโ”€โ”€ data/locale/en-US.ini
โ”œโ”€โ”€ docs/                                     # GitHub Pages documentation
โ”‚   โ”œโ”€โ”€ index.html
โ”‚   โ”œโ”€โ”€ ZOOM_MARKETPLACE_OAUTH.md             # OAuth PKCE setup guide
โ”‚   โ””โ”€โ”€ policies/                             # Security & privacy policy documents
โ”œโ”€โ”€ engine/src/                               # ZoomObsEngine process (owns ALL SDK access)
โ”‚   โ”œโ”€โ”€ main.cpp                              # IPC loop, SDK auth/join, roster/spotlight tracking
โ”‚   โ”œโ”€โ”€ engine-video.cpp/h                    # IZoomSDKRenderer โ†’ named shared memory (I420)
โ”‚   โ””โ”€โ”€ engine-audio.cpp/h                    # SDK audio โ†’ named shared memory (PCM)
โ””โ”€โ”€ src/                                      # OBS plugin (no SDK linkage)
    โ”œโ”€โ”€ plugin-main.cpp                       # Module load/unload, dock, Tools menu, SIGPIPE
    โ”œโ”€โ”€ zoom-source.*                         # Participant source (ShmRegion, AssignmentMode,
    โ”‚                                         #   HwVideoPipeline, failover, hotkeys, placeholder)
    โ”œโ”€โ”€ zoom-engine-client.*                  # Singleton: IPC, engine launch, roster, callbacks,
    โ”‚                                         #   spotlight/screenshare subscribe, monitor thread
    โ”œโ”€โ”€ zoom-oauth.*                          # ZoomOAuthManager: broker PKCE, SDK JWT fetch, token storage
    โ”œโ”€โ”€ oauth-callback-helper.cpp             # Windows: CoreVideoOAuthCallback.exe entry point
    โ”œโ”€โ”€ oauth-callback-helper-macos.mm        # macOS: CoreVideoOAuthCallback.app entry point
    โ”œโ”€โ”€ zoom-dock.*                           # Qt dockable panel: CvStatusDot, CvBanner,
    โ”‚                                         #   token-type selector, recovery countdown
    โ”œโ”€โ”€ zoom-reconnect.*                      # Auto-reconnect manager (exponential back-off)
    โ”œโ”€โ”€ zoom-types.h                          # Shared enums + ZoomJoinAuthTokens struct
    โ”œโ”€โ”€ cv-style.h                            # CoreVideo Qt stylesheet (dark theme, button roles)
    โ”œโ”€โ”€ cv-widgets.*                          # CvStatusDot (animated dot), CvBanner (notice strip)
    โ”œโ”€โ”€ hw-video-pipeline.*                   # FFmpeg I420โ†’NV12 (CUDA/VAAPI/VideoToolbox/QSV)
    โ”œโ”€โ”€ zoom-audio-delegate.*                 # Mixed / isolated SDK audio โ†’ OBS
    โ”œโ”€โ”€ zoom-audio-router.*                   # Central SDK audio fan-out dispatcher
    โ”œโ”€โ”€ zoom-auth.*                           # SDK JWT auth + observable auth state
    โ”œโ”€โ”€ zoom-meeting.*                        # Meeting state machine
    โ”œโ”€โ”€ zoom-participants.*                   # Roster, active speaker, spotlight callbacks
    โ”œโ”€โ”€ zoom-participant-audio-source.*       # Per-participant audio OBS source
    โ”œโ”€โ”€ zoom-interpretation-audio-source.*    # Language interpretation channel OBS source
    โ”œโ”€โ”€ zoom-video-delegate.*                 # Video frames, resolution, loss mode, preview
    โ”œโ”€โ”€ zoom-share-delegate.*                 # Screen share frames โ†’ OBS source
    โ”œโ”€โ”€ zoom-output-manager.*                 # Source registry + runtime reconfiguration
    โ”œโ”€โ”€ zoom-output-profile.*                 # Named JSON profile persistence
    โ”œโ”€โ”€ zoom-output-dialog.*                  # Qt Output Manager dock widget
    โ”œโ”€โ”€ zoom-control-server.*                 # TCP JSON control API (port 19870) + oauth_callback
    โ”œโ”€โ”€ zoom-osc-server.*                     # UDP OSC control API (port 19871)
    โ”œโ”€โ”€ zoom-settings.*                       # Broker URL, OAuth tokens, local ports + reconnect persistence
    โ”œโ”€โ”€ zoom-settings-dialog.*                # Qt Settings dialog (Zoom sign-in + local settings)
    โ”œโ”€โ”€ zoom-credentials.h.in                 # Embedded SDK credentials (CMake-generated)
    โ”œโ”€โ”€ obs-zoom-version.h.in                 # Plugin version string (CMake-generated)
    โ”œโ”€โ”€ engine-ipc.h                          # IPC constants + cross-platform pipe/socket helpers
    โ””โ”€โ”€ obs-utils.*                           # OBS helper functions

Platform Matrix

PlatformCompilerSDK LinkageIPC TransportEngine
WindowsMSVC 2022sdk.lib + sdk.dllNamed pipesYes
macOSClang 14+ZoomSDK.frameworkUnix socketsYes
LinuxGCC 11+libsdk.soUnix socketsYes
โœ… macOS build
Targets macOS 12.0+, produces a universal binary (arm64 + x86_64). Qt6::Network is required on all platforms for the TCP and OSC servers.

Auto ISO Recording

CoreVideo can record assigned participant media as separate ISO files while optionally starting the main OBS program recording. ISO recording is owned by the OBS plugin process: the engine remains minimal and only publishes raw I420 video and PCM audio through the existing IPC and shared-memory path.

OBS ISO Recorder Panel

Open Tools โ†’ Zoom ISO Recorder to manage ISO recording from a separate OBS dock. The panel lets operators choose an output folder, set or test the FFmpeg executable, choose whether to also control OBS program recording, start/stop ISO recording, and monitor active ISO sessions with source, participant, resolution, frame counts, audio chunk counts, and output file paths.

The panel uses the same ZoomIsoRecorder backend as the TCP and OSC APIs and persists its folder, FFmpeg path, and program-recording toggle in OBS global settings.

CoreVideo ISO recording flow
Assignments resolve the participant IDs. Each assigned Zoom source can feed an FFmpeg writer for video/audio ISO files while OBS continues to own the program output.

Start Recording

{
  "cmd": "iso_recording_start",
  "output_dir": "C:/Recordings/CoreVideo",
  "ffmpeg_path": "ffmpeg",
  "record_program": true
}

Check Status

{ "cmd": "iso_recording_status" }

Stop Recording

{ "cmd": "iso_recording_stop" }

OSC Controls

AddressArgumentsAction
/zoom/iso/startoutput_dir, optional record_programStarts ISO recording
/zoom/iso/stopnoneStops ISO recording
Source assignment triggers the ISOs
ISO files are created for participants that are assigned through fixed, active-speaker, spotlight, or screen-share workflows. If the active speaker changes, the recorder follows the resolved assignment and keeps participant file naming stable by participant ID and display name.

See the Core Plugin Functionality guide for complete TCP examples, OSC examples, troubleshooting notes, and the plugin media pipeline diagrams.

Modern UI (Phase 1)

CoreVideo applies its own scoped Qt stylesheet and custom widgets to the plugin UI, giving it a consistent dark-theme appearance without interfering with OBS's global QApplication stylesheet.

CoreVideo Stylesheet (cv-style.h)

Applied widget-level via cv_stylesheet(). Covers group boxes, buttons (with role variants), line edits, combo boxes, tables, and scroll bars.

Button role propertyAppearanceUse
role = "primary"Zoom-blue accent fillAffirmative actions: Join, Apply, Save
role = "danger"Red tint fillDestructive or exit actions: Leave, Delete, Disconnect
(default)Neutral dark fillAll other buttons

CvStatusDot

A QWidget subclass that draws a coloured status indicator using QPainter rather than a Unicode bullet character. In transitional states (Joining, Leaving, Recovering) a QTimer drives a sinusoidal pulse glow animation.

MeetingStateColourAnimation
IdleGrey #555Static
JoiningAmber #d29922Pulse glow
InMeetingGreen #3fb950Static
LeavingAmber #d29922Pulse glow
RecoveringOrange #e36209Pulse glow
FailedRed #f85149Static

CvBanner

A compact QFrame-derived notice strip. Supports three kinds: Info (blue), Warning (amber), Error (red). An optional underlined action button on the right emits actionClicked(). Used in ZoomDock as a first-run prompt to configure SDK credentials.