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):
- Jibri’s launcher service receives an XMPP recording signal and launches Chrome via Selenium WebDriver
- Chrome navigates directly to
https://meet.example.com/roomnameand joins as a regular XMPP participant - Jibri reads conference state by querying
APP.conference/APP.storeinternals - State updates arrive using DOM mutation observers
- FFmpeg captures the rendered page and audio into MP4/RTMP
ExternalAPIPage (new, iframe-based):
recorder.html, bundled as a resource in the Jibri JAR, is extracted to a local temp file and loaded viafile://, with room/baseUrl/tenant/config passed as URL query parametersrecorder.htmlloadsexternal_api.jsfrom the Jitsi Meet deployment’sbaseUrl, creates aJitsiMeetExternalAPIinstance, and embeds the conference in an iframe- Jibri reads state and sends commands by calling
driver.executeAsyncScript()intowindow.jibriRecorderApi, theJitsiMeetExternalAPIinstance itself. For example,getNumParticipants()readsjibriRecorderApi.getRoomsInfo(), andtoggleAudioMute()sendsjibriRecorderApi.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
| Description | PR |
|---|---|
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
| Description | PR |
|---|---|
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
| Description | PR |
|---|---|
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.