Compose a Fishjam room
A composition can include the peers of a Fishjam room. You forward the room's tracks to the composition with a single Fishjam API call: Fishjam then pushes each participant's media into the composition as inputs, and hooks from @fishjam-cloud/composition let a template render one tile per participant and push the result to a livestream.
Fishjam room (peers) ββforwardedβββΆ composition (template) ββWHIPβββΆ Fishjam livestream ββWHEPβββΆ [viewers]
Only conference and audio-only rooms can be forwarded. Forwarding a livestream room fails.
To compose a single stream, such as OBS, a phone camera, or a browser tab, you don't need a room at all. Publish it straight into the composition through a whip_server input.
Prerequisitesβ
- A livestream (or any other WHIP/RTMP destination) to publish the composed stream to.
- A template project scaffolded with the composition CLI (see Write and deploy a template).
The Composition API lives on https://rtc.fishjam.io, while rooms and livestreams live on the Fishjam Server API. Both take the same Management Token:
export FISHJAM_URL="https://fishjam.io/api/v1/connect/<YOUR_FISHJAM_ID>" export TOKEN="<YOUR_MANAGEMENT_TOKEN>"
Step 1: Create a room and invite peersβ
Compositions consume h264 video, so the room has to enforce that codec. It is the default, but set it explicitly so a change of default cannot break the composition later:
- Typescript
- Python
- curl
constroom = awaitfishjamClient .createRoom ({roomType : "conference",videoCodec : "h264", });
from fishjam import FishjamClient, RoomOptions fishjam_client = FishjamClient(fishjam_id=fishjam_id, management_token=management_token) room = fishjam_client.create_room( RoomOptions(room_type="conference", video_codec="h264"), )
curl -X POST "$FISHJAM_URL/room" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "roomType": "conference", "videoCodec": "h264" }'
The room id comes back under data.room.id. Save it:
export ROOM_ID="<ROOM_ID>"
Every participant needs their own peer token. Create one per person:
- Typescript
- Python
- curl
const {peer ,peerToken } = awaitfishjamClient .createPeer (roomId );
peer, peer_token = fishjam_client.create_peer(room.id)
curl -X POST "$FISHJAM_URL/room/$ROOM_ID/peer" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "type": "webrtc", "options": {} }'
Hand each data.token to a client and have it join, using Connect to a room or the React quick start. The composition renders whoever is publishing, so get at least one peer in with a camera on before you expect a picture.
Step 2: Write a room-aware templateβ
Hooks from @fishjam-cloud/composition give the template live room state; it re-renders automatically as participants join, leave, mute, or speak.
import {InputStream ,Rescaler ,Text ,Tiles ,View } from "@swmansion/smelter"; import {usePeers ,useSpeakingState } from "@fishjam-cloud/composition"; import type {PeerWithStreams } from "@fishjam-cloud/composition"; typePeerMetadata = {displayName ?: string }; functionPeerTile ({peer }: {peer :PeerWithStreams <PeerMetadata > }) { constcamera =peer .cameraStream ; constcameraOn =camera ?.video && !camera .video .paused ; constspeaking =useSpeakingState (peer .id ) === "speech"; constname =peer .metadata .peer ?.displayName ??peer .id ; return ( <View style ={{borderWidth :speaking ? 4 : 0,borderColor : "#00cc66ff" }}> {cameraOn ? ( <Rescaler > <InputStream inputId ={camera .inputId } /> </Rescaler > ) : ( <View > <Text >{name }</Text > </View > )} </View > ); } export default functionApp () { constpeers =usePeers <PeerMetadata >(); constconnected =peers .filter ((peer ) =>peer .streams .length > 0); return ( <View style ={{backgroundColor : "#0b1020ff" }}> <Tiles style ={{padding : 8 }}> {connected .map ((peer ) => ( <PeerTile peer ={peer }key ={peer .id } /> ))} </Tiles > </View > ); }
| Hook | Returns |
|---|---|
usePeers() | All peers in the forwarded room, each with its streams (cameraStream, screenShareStream, customStreams). |
usePeer(peerId) | A single peer, or undefined. |
useRoom() | The forwarded room { id }, or undefined before a room is forwarded. |
useSpeakingState(peerId) | "speech" or "silence" for active-speaker highlighting. |
The key link is stream.inputId: you pass it to <InputStream inputId={β¦} /> to render that participant's forwarded track. A peer's streams fill in once its media actually starts flowing into the composition.
Build the bundle as usual with npm run build.
Step 3: Create the composition and register the templated outputβ
Create the composition with auto-start off, because the room's inputs only appear once forwarding starts.
Setting cleanup_without_inputs to false tightens the cleanup condition, so that it now takes both the inputs and the outputs going quiet rather than the inputs alone. Cleanup therefore fires less readily, and the composition survives the wait for the first peer to publish.
With that guard off, cleanup waits for the outputs to fall silent too, so one that keeps publishing is never cleaned up for you. Delete it as soon as you are finished, and do not leave one running after a test. See Cost and lifecycle.
- Typescript
- Python
- curl
const {compositionId } = awaitcompositionClient .createComposition ({autostart : false,cleanupWithoutInputs : false, });
from fishjam import CompositionClient from fishjam.composition import CreateCompositionRequest composition_client = CompositionClient(management_token=management_token) composition = composition_client.create_composition( CreateCompositionRequest(autostart=False, cleanup_without_inputs=False), ) composition_id = composition.composition_id
curl -X POST "https://rtc.fishjam.io/api/composition" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "autostart": false, "cleanup_without_inputs": false }'
Save the composition_id from the response, every call below uses it:
export COMPOSITION="<COMPOSITION_ID>"
Register a templated whip_client output that pushes to your livestream's WHIP endpoint. Create the livestream and its streamer token first, as in Step 3 of the tutorial. The output configuration and the template bundle go together in one multipart request:
- Typescript
- Python
- curl
const {token :streamerToken } = awaitfishjamClient .createLivestreamStreamerToken (livestreamId ); awaitcompositionClient .registerTemplateOutput (compositionId ,outputId , {type : "whip_client",endpointUrl :fishjamClient .livestreamWhipUrl (),bearerToken :streamerToken ,video : {resolution : {width : 1280,height : 720 },initial : {root : {type : "view" } }, },audio : {initial : {inputs : [] } }, }, "dist/App.js", );
from fishjam.composition import ( AudioScene, OutputWhipAudioOptions, OutputWhipVideoOptions, Resolution, VideoScene, View, ViewType, WhipOutput, WhipOutputType, ) streamer_token = fishjam_client.create_livestream_streamer_token(livestream_id) composition_client.register_template_output( composition_id, "main", WhipOutput( type_=WhipOutputType.WHIP_CLIENT, endpoint_url=fishjam_client.livestream_whip_url(), bearer_token=streamer_token, video=OutputWhipVideoOptions( resolution=Resolution(width=1280, height=720), initial=VideoScene(root=View(type_=ViewType.VIEW)), ), audio=OutputWhipAudioOptions(initial=AudioScene(inputs=[])), ), "dist/App.js", )
curl -X POST "https://rtc.fishjam.io/api/composition/$COMPOSITION/output/main/template" \ -H "Authorization: Bearer $TOKEN" \ -F 'config={ "type": "whip_client", "endpoint_url": "<LIVESTREAM_WHIP_URL>", "bearer_token": "<LIVESTREAM_STREAMER_TOKEN>", "video": { "resolution": { "width": 1280, "height": 720 }, "initial": { "root": { "type": "view" } } }, "audio": { "initial": { "inputs": [] } } };type=application/json' \ -F "template=@dist/App.js"
Step 4: Forward the room into the compositionβ
One call to forwardRoomTracks wires everything up:
- Typescript
- Python
- curl
awaitfishjamClient .forwardRoomTracks (roomId ,compositionClient .compositionUrl (compositionId ), );
fishjam_client.forward_room_tracks( room.id, composition_client.composition_url(composition_id), )
curl -X POST "$FISHJAM_URL/room/$ROOM_ID/track_forwardings" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{ \"compositionURL\": \"https://rtc.fishjam.io/api/composition/$COMPOSITION\", \"selector\": \"all\" }"
Everything else happens automatically: Fishjam links the room to the composition, registers an input for every forwarded track, and streams the media in. Your template's usePeers() fills with the room's peers as their media starts flowing. You never register room inputs by hand.
A room and a composition pair up one to one, in both directions. Repeating the call with the same compositionURL is a no-op, but pointing the room at a second composition, or a second room at this composition, fails. To compose two rooms, give each its own composition.
Step 5: Start the compositionβ
- Typescript
- Python
- curl
awaitcompositionClient .startComposition (compositionId );
composition_client.start_composition(composition_id)
curl -X POST "https://rtc.fishjam.io/api/composition/$COMPOSITION/start" \ -H "Authorization: Bearer $TOKEN"
Viewers can now watch the composed grid through the livestream's WHEP endpoint. If you'd like to keep an MP4 recording of the room without streaming it live, simply record it with the same template.
Step 6: Clean upβ
Delete the composition when you are done. Forwarding stops on the Fishjam side when the room itself stops, so delete the room too once you no longer need it. There is no separate call to remove a forwarding from a live room.
- Typescript
- Python
- curl
awaitcompositionClient .deleteComposition (compositionId ); awaitfishjamClient .deleteRoom (roomId );
composition_client.delete_composition(composition_id) fishjam_client.delete_room(room.id)
curl -X DELETE "https://rtc.fishjam.io/api/composition/$COMPOSITION" \ -H "Authorization: Bearer $TOKEN" curl -X DELETE "$FISHJAM_URL/room/$ROOM_ID" \ -H "Authorization: Bearer $TOKEN"