Project Overview

Jibri (Jitsi BRoadcasting Infrastructure) records and live-streams Jitsi Meet conferences by launching a headless Chrome instance that joins the conference as a participant, then captures the media with FFmpeg.

Jibri’s pre-existing approach reads conference state by visiting the Jitsi Meet page and reaching into its internals (parsing the DOM, querying APP.conference and APP.store). This project adds an alternative: driving Jibri through Jitsi Meet’s External API instead, the same event-driven, iframe-based interface third-party apps use to embed Jitsi Meet. Both modes are supported today, toggled by a feature flag.

Mentors: Damyan Minkov (damencho), Jaya Allamsetty

Two Recording Modes

Two recording modes toggled by a feature flag (jibri.selenium.use-external-api, default false) in jibri.conf:

AppCallPage (pre-existing, internal state-based):

  1. Jibri’s launcher service receives an XMPP recording signal and launches Chrome via Selenium WebDriver
  2. Chrome navigates directly to https://meet.example.com/roomname and joins as a regular XMPP participant
  3. Jibri reads conference state by querying APP.conference/APP.store internals
  4. State updates arrive using DOM mutation observers
  5. FFmpeg captures the rendered page and audio into MP4/RTMP

ExternalAPIPage (new, iframe-based):

  1. recorder.html, bundled as a resource in the Jibri JAR, is extracted to a local temp file and loaded via file://, with room/baseUrl/tenant/config passed as URL query parameters
  2. recorder.html loads external_api.js from the Jitsi Meet deployment’s baseUrl, creates a JitsiMeetExternalAPI instance, and embeds the conference in an iframe
  3. Jibri reads state and sends commands by calling driver.executeAsyncScript() into window.jibriRecorderApi, the JitsiMeetExternalAPI instance itself. For example, getNumParticipants() reads jibriRecorderApi.getRoomsInfo(), and toggleAudioMute() sends jibriRecorderApi.executeCommand('toggleAudio')

Implementation

Today, in ExternalAPIPage mode, Jibri can: join a conference, read its state, send commands like muting or raising a hand, and detect empty calls to stop recording automatically, matching everything AppCallPage already does.

Shared CallPage interface. Both AppCallPage and ExternalAPIPage implement a common CallPage interface, so the rest of Jibri only depends on CallPage’s methods, never on which implementation is actually running. A factory function, CallPage.create(), picks the implementation at runtime based on the feature flag.

Feature parity. ExternalAPIPage tracks the same conference state as AppCallPage (participant counts, mute state, force-mute state, ICE connection state, Jigasi participant detection, hidden/visitor/kick detection, hand-raise, and presence properties) and implements the same actions.

recorder.html. A static page bundled inside the Jibri JAR that creates a JitsiMeetExternalAPI instance, embeds the conference in an iframe, and exposes the instance as window.jibriRecorderApi for Jibri’s WebDriver calls to read state and send commands.

Code Contributions

19 features shipped, related to jibri, jitsi-meet, and lib-jitsi-meet repositories documented in Jitsi handbook. All merged.

jibri

DescriptionPR
Load the recorder page (visit(), initial page setup)#609
Detect participant count and empty calls (getNumParticipants() / isCallEmpty())#610
Expose per-participant bitrate stats (getBitrates())#612
Hide recorder from participant list#613
Fix recorder visibility (localStorage credentials)#614
Detect ICE connection state (isIceConnected())#615
Count Jigasi (SIP bridge) participants (numRemoteParticipantsJigasi())#616
Recorder hangup (leave())#617
Send/update recorder presence and participant properties (addToPresence() / sendPresence() / setParticipantProperties())#618
Detect local audio/video mute state (isLocalAudioMuted() / isLocalVideoMuted())#620
Toggle local audio/video mute (toggleVideoMute() / toggleAudioMute())#621
Count muted remote participants (numRemoteParticipantsMuted())#622
Detect if the recorder was kicked (isLocalParticipantKicked())#623
Detect force-mute state (isAudioForceMuted() / isVideoForceMuted())#624
Raise hand, recorder signaling (raiseHand())#627
Unmute the recorder (unmute())#626
Count hidden participants (numHiddenParticipants())#628
Detect visitor role (isVisitor())#629
Fetch participant identities for recording metadata (getParticipants())#630
Fix local recording video on Wayland (black screen)#632

jitsi-meet

DescriptionPR
Expose per-participant bitrate stats (getBitrates())#17594
Expose ICE connection state (isIceConnected())#17620
Expose Jigasi (SIP bridge) participant flag (numRemoteParticipantsJigasi())#17624
Support recorder presence and participant properties (addToPresence() / sendPresence() / setParticipantProperties())#17639 + lib-jitsi-meet #3071
Expose muted-participant count (numRemoteParticipantsMuted())#17639

Jitsi handbook

DescriptionPR
Document getBitrates()#651
Document isIceConnected()#657
Document numRemoteParticipantsJigasi()#658
Document presence properties (addToPresence() / sendPresence() / setParticipantProperties())#673
Document numRemoteParticipantsMuted()#675
Document numHiddenParticipants()#678

Challenges & Learnings

Serving recorder.html without a web server. The initial plan was to serve recorder.html through Jibri’s own REST API, but damencho suggested keeping it as a static file instead, extracted from the Jibri JAR and loaded via file://, no web server needed. Issue: recorder.html still needs to load external_api.js from the Jitsi Meet deployment, and a page loaded from file:// has no origin. The patch: pass the deployment’s baseUrl as a query parameter and load external_api.js from it via a <script> tag, which works regardless of recorder.html’s own file:// origin.

Getting credentials into the iframe. recorder.html needs Jibri’s login credentials to join as an authenticated participant. With AppCallPage, Jibri sets localStorage directly on the Jitsi Meet page via Selenium, since Chrome navigates there. recorder.html has no origin to set credentials on, and the actual conference runs inside an iframe with its own separate origin. The fix: JitsiMeetExternalAPI has no credentials option, but it reads the host page’s window.localStorage and forwards it to the iframe if useHostPageLocalStorage: true is set. So recorder.html seeds localStorage with Jibri’s credentials before constructing the API, with that flag on. This needed a second pass, fixing a double-JSON.stringify bug in recorder.html.

Local recording on Wayland. Local recordings using Wayland produced blank, empty frames. It came down to two things: Chrome’s DISPLAY was hardcoded to :0 while FFmpeg captured Xvfb’s :1, and Wayland’s auto-detection was bypassing X11 rendering entirely. I fixed both: made JibriSelenium.kt read the display from config, and forced --ozone-platform=x11 to stop Chrome falling back to Wayland.

A split local/docker setup. I started with a Docker setup, but switched to running Jibri and Jitsi Meet on the host for faster iteration. This project also touched jitsi-meet’s own External API, and the Docker deployment ran a build without those changes, so the setup split in two: Jibri, Jitsi Meet’s web frontend, and a virtual display ran locally on the host, with the rest of the Jitsi stack (JVB, Jicofo, Prosody) running in Docker. This meant juggling separate logs (Chrome/Selenium, FFmpeg, Jibri’s log).

Future Work

  • Add end-to-end tests with Playwright

Acknowledgements

Thanks to Damyan Minkov (damencho) and Jaya Allamsetty for their mentorship, and the Jitsi community for their support along the way. Damencho’s involvement through interaction and review was key to the project’s progress.