- One pipeline, shared by the pre-join preview and the call, attached the first time an effect is wanted and never detached after; turning blur off switches it to a disabled mode in place. Main dropped the processor whenever blur went off, and a pipeline taken off the camera is destroyed and re-primed, so each time blur came back one unprocessed frame went out. Built once, the priming frame is spent once. - Still driven by the existing blur setting: nothing a user sees changes. - Its logic is BackgroundEffects in src/state/, which the provider only builds, so the tests drive it without rendering anything. - Disabled drops the blur radius too. The SDK's disabled mode keeps it, and with a radius every frame is still segmented and thrown away; with neither a radius nor a picture, frames pass untouched. - Switches run one at a time and skip any overtaken, so an earlier, slower switch can't land after a later choice. - Builds, rebuilds and teardowns of the pipeline run one at a time (OneStepPipeline). The preview's and the call's tracks attach and stop it independently, and the SDK's wrapper holds one track's streams: two builds at once overwrite each other's, and a stop during a build does nothing. - Every camera track starts with the pipeline on: the room's capture defaults follow it, and a camera turned off before the first effect is replaced on turning it on, since a stopped track can't take one. One attached after publishing lets the room through while it is built. - BlurBackgroundTransformer becomes BackgroundEffectTransformer: it also sits disabled, and its init applies that state as the SDK's would. Rebased onto main, where src/livekit moved to src/tracks and publishing moved into the SDK: - Publisher.ts is gone. Starting each camera track with the pipeline is now the processor on the camera publish request, at the join and on turning the camera on (CallViewModel). Replacing a camera turned off before the first effect is done in the camera's mute handler, which unpublishes and publishes it anew; its tests moved to CallViewModel.test.ts. The SDK's publisher, starting the microphone and camera in one call, takes the requests' options into the room's capture defaults, since a camera turned on before the start would otherwise come up without the processor. - The SDK gives one VideoProcessor one LiveKit adapter, and setProcessor leaves a track alone that already runs it, so a track made with the pipeline isn't torn down when the call sets it again. - BackgroundEffects wraps the pipeline once with fromLivekitProcessor, so the call and preview see one VideoProcessor throughout. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
MatrixRTC SDK (EXPERIMENTAL)
@element-hq/matrixrtc-sdk is the call model under Element Call, on its own:
it joins a MatrixRTC slot in a Matrix room, writes and watches the memberships,
connects to the media transport (LiveKit today), publishes the local media and
exposes every member and their tracks as observables. It has no UI. Element
Call's own CallViewModel is one consumer of it; any host can build a different
view on top. The design is in SdkArchitecture.md, what
moved out of Element Call's src/ in SdkMigration.md.
Status: first implementation. createRTCSlot and slot.join() join the
session, connect to the transport, publish the local media and expose every
member's media; the development harness and its e2e tests in playwright/sdk
drive it, and Element Call's own CallViewModel is built on it. It depends on
nothing in Element Call's src/.
Using it
import {
createRTCSlot,
E2eeType,
MatrixRTCMode,
ObservableScope,
trackBySource$,
} from "@element-hq/matrixrtc-sdk";
const scope = new ObservableScope();
const slot = createRTCSlot(
scope,
client, // a matrix-js-sdk MatrixClient, logged in and syncing
room, // the matrix-js-sdk Room, from client.getRoom() once the join has synced
{
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
matrixRTCMode: MatrixRTCMode.Compatibility,
},
);
const participation = slot.join({
publish: [{ source: "microphone" }, { source: "camera" }],
});
// A <video> for each remote member's camera and an <audio> for its microphone.
// A track plays on an element once attached to it; detach before the element
// leaves the DOM. (`tileFor`, `setName` and `removeTilesExcept` are the host's.)
participation.remoteMembers$.subscribe((members) => {
for (const member of members) {
const { video, audio } = tileFor(member.memberId);
member.displayName$.subscribe((name) => setName(member.memberId, name));
trackBySource$(scope, member.tracks$, "camera").subscribe((track) =>
track?.attach(video),
);
trackBySource$(scope, member.tracks$, "microphone").subscribe((track) =>
track?.attach(audio),
);
}
removeTilesExcept(members.map((m) => m.memberId));
});
// later
participation.leave();
scope.end();
participation.localMember$ is one member of the same shape, null until our own
membership is in the room; its microphone would only echo.
dev/main.ts is this example as a working page.
Reading the code
Three files define the surface. Read them in this order.
src/api.ts: the MatrixRTC side. AnRTCSlotis the namespace in a room that memberships join into;join()returns anRTCParticipation, which lasts until itsleave()and exposesstate$,localMember$andremoteMembers$. Members, transports and the participation state machine live here.createRTCSlotinRTCSlot.tsis the only way to get an implementation.src/media-api.ts: media as a host sees it, onremoteMembers$andlocalMember$.PublishRequestsays what to publish (microphone, camera, screen share);MediaTrackis how it arrives. The underlying tracks stay hidden: the host hands an element toattachand the SDK plays into it. The local variants carry the controls (setEnabled,setDevice,setProcessor).src/media-backend/api.ts: internal, the seam between the session and whatever carries the media. The session speaks MatrixRTC; aMediaBackendspeaks one media protocol and hands back theMemberMediaof each member throughmediaFor(). A host rarely touches it; it matters when working on the LiveKit backend undersrc/media-backend/livekit/, the only place that importslivekit-client, or when writing a new backend.
Development
Everything runs from the repository root. This directory is a pnpm project of its
own only so that a host can install it as a git dependency (see
pnpm-workspace.yaml).
pnpm dev:sdk # the harness, https://localhost:3002
pnpm build:sdk # dist/matrixrtc-sdk.js and dist/types
pnpm test:playwright playwright/sdk # needs `pnpm backend`
Installing
pnpm add github:element-hq/element-call#<ref>&path:/sdk
The host supplies matrix-js-sdk, livekit-client and rxjs; the build leaves
them external.