Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions conference-to-stream/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Fishjam ID or full URL (e.g. your-id or https://fishjam.io/api/v1/connect/your-id)
FISHJAM_ID=your-fishjam-id

# Fishjam management token
FISHJAM_MANAGEMENT_TOKEN=your-management-token

# Backend port (default: 8080)
PORT=8080

# Frontend: Fishjam ID passed to FishjamProvider
VITE_FISHJAM_ID=your-fishjam-id
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Would be nice not to duplicate the id


# Frontend: backend URL
VITE_BACKEND_URL=http://localhost:8080
1 change: 1 addition & 0 deletions conference-to-stream/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.env
78 changes: 78 additions & 0 deletions conference-to-stream/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Conference to Stream

A demo showcasing Fishjam's track forwarding capability combined with [Foundry](https://compositor.live) (Smelter) for real-time video composition. Participants join a video conference, their tracks are automatically forwarded to Foundry, composed into a single stream using a Tiles layout, and made available as a WHEP stream that can be previewed alongside the conference.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: As above, just refer to this as the compositing api, we don't want too many custom names

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue:

Suggested change
A demo showcasing Fishjam's track forwarding capability combined with [Foundry](https://compositor.live) (Smelter) for real-time video composition. Participants join a video conference, their tracks are automatically forwarded to Foundry, composed into a single stream using a Tiles layout, and made available as a WHEP stream that can be previewed alongside the conference.
A demo showcasing Fishjam's track forwarding capability combined with [Foundry](https://www.smelter.dev) (Smelter) for real-time video composition. Participants join a video conference, their tracks are automatically forwarded to Smelter instance hosted by Fishjam, composed into a single stream using a Tiles layout, and made available as a WHEP stream that can be previewed alongside the conference.


## How It Works

1. The backend creates a Foundry composition and a Fishjam conference room with track forwarding enabled.
2. When a participant joins and publishes their camera/microphone, Fishjam forwards the tracks to Foundry.
3. The backend listens for `TrackForwarding` notifications via WebSocket and updates the Foundry composition layout (Tiles grid + audio mix).
4. The frontend displays the conference (via Fishjam React SDK) side-by-side with a live WHEP preview of the composed stream.

## Running Locally

Before running, copy `.env.example` to `.env` and set the following values:

```bash
FISHJAM_ID=...
FISHJAM_MANAGEMENT_TOKEN=...
VITE_FISHJAM_ID=...
```

You can get `FISHJAM_ID` and `FISHJAM_MANAGEMENT_TOKEN` for free by logging in at <https://fishjam.io/app>. `VITE_FISHJAM_ID` should be set to the same value as `FISHJAM_ID`.

### Docker Compose (Recommended)

The easiest way to run the app is with [Docker Compose](https://docs.docker.com/compose/install/).

```bash
docker compose --env-file .env up --build
```

The web UI will be available at <http://localhost:5173> and the backend at <http://localhost:8080>.

### Running Manually

#### Requirements
- [Go](https://go.dev/dl/) `>= 1.23`
- [Node.js](https://nodejs.org/en/download) `>= 20`

#### Backend

```bash
cd backend
go run main.go
```

The server starts on <http://localhost:8080> by default.

#### Frontend

```bash
cd web
npm install
npm run dev
```

Open the UI at <http://localhost:5173>.

## Repo Structure

- `backend/` — Go server that orchestrates Fishjam rooms and Foundry compositions.
- `fishjam/` — REST client for rooms, peers, and track forwarding + WebSocket notification listener (protobuf).
- `foundry/` — HTTP client for Foundry composition API (create, start, register/update output).
- `handler/` — HTTP handlers and in-memory room state management.
- `proto/` — Generated protobuf Go code for Fishjam server notifications.
- `web/` — React + Vite frontend.
- `components/JoinForm.tsx` — Room name and user name form.
- `components/Conference.tsx` — Peer grid with camera/mic controls + WHEP preview sidebar.
- `components/WhepPlayer.tsx` — WHEP stream player using WebRTC.
- `whep.ts` — WHEP client (SDP negotiation over HTTP).

### Tech Stack

- [Fishjam](https://fishjam.io) for real-time videoconferencing and track forwarding.
- [Foundry / Smelter](https://compositor.live) for real-time video composition.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

- Go backend with direct HTTP/WebSocket calls (no SDK).
- React + Vite frontend with `@fishjam-cloud/react-client`.
- Tailwind CSS for styling.
12 changes: 12 additions & 0 deletions conference-to-stream/backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
FROM golang:1.26-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o server .

FROM alpine:3.20
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
CMD ["./server"]
181 changes: 181 additions & 0 deletions conference-to-stream/backend/fishjam/client.go
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Generating a client from the openapi instead would be preferred and more educational imo

Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package fishjam

import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
)

type Client struct {
baseURL string
managementToken string
httpClient *http.Client
}

type RoomConfig struct {
RoomType string `json:"roomType,omitempty"`
}

type Room struct {
ID string `json:"id"`
Config any `json:"config"`
Peers []Peer `json:"peers"`
}

type Peer struct {
ID string `json:"id"`
Type string `json:"type"`
}

type PeerConfig struct {
Type string `json:"type"`
Options PeerOptionsWeb `json:"options"`
}

type PeerOptionsWeb struct {
Metadata map[string]string `json:"metadata,omitempty"`
}

type TrackForwardingRequest struct {
CompositionURL string `json:"compositionURL"`
Selector string `json:"selector"`
}

type createRoomResponse struct {
Data struct {
Room Room `json:"room"`
} `json:"data"`
}

type createPeerResponse struct {
Data struct {
Peer Peer `json:"peer"`
Token string `json:"token"`
PeerWebsocketURL string `json:"peer_websocket_url"`
} `json:"data"`
}

func NewClient(fishjamID, managementToken string) *Client {
baseURL := fishjamID
if _, err := url.ParseRequestURI(fishjamID); err != nil || !strings.HasPrefix(fishjamID, "http") {
baseURL = fmt.Sprintf("https://fishjam.io/api/v1/connect/%s", fishjamID)
}
baseURL = strings.TrimRight(baseURL, "/")

return &Client{
baseURL: baseURL,
managementToken: managementToken,
httpClient: &http.Client{},
}
}

func (c *Client) BaseURL() string {
return c.baseURL
}

func (c *Client) ManagementToken() string {
return c.managementToken
}

func (c *Client) CreateRoom() (*Room, error) {
body := RoomConfig{RoomType: "conference"}
resp, err := c.doJSON("POST", "/room", body)
if err != nil {
return nil, fmt.Errorf("create room: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusCreated {
return nil, readError(resp)
}

var result createRoomResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode create room response: %w", err)
}
return &result.Data.Room, nil
}

func (c *Client) CreatePeer(roomID string, metadata map[string]string) (peerToken string, peerWebsocketURL string, err error) {
body := PeerConfig{
Type: "webrtc",
Options: PeerOptionsWeb{Metadata: metadata},
}
resp, err := c.doJSON("POST", fmt.Sprintf("/room/%s/peer", roomID), body)
if err != nil {
return "", "", fmt.Errorf("create peer: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusCreated {
return "", "", readError(resp)
}

var result createPeerResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", "", fmt.Errorf("decode create peer response: %w", err)
}
return result.Data.Token, result.Data.PeerWebsocketURL, nil
}

func (c *Client) CreateTrackForwarding(roomID, compositionURL string) error {
body := TrackForwardingRequest{
CompositionURL: compositionURL,
Selector: "all",
}
resp, err := c.doJSON("POST", fmt.Sprintf("/room/%s/track_forwardings", roomID), body)
if err != nil {
return fmt.Errorf("create track forwarding: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusCreated {
return readError(resp)
}
return nil
}

func (c *Client) doJSON(method, path string, body any) (*http.Response, error) {
jsonBody, err := json.Marshal(body)
if err != nil {
log.Printf("fishjam: marshal error for %s %s: %v (body: %+v)", method, path, err, body)
return nil, fmt.Errorf("marshal request body: %w", err)
}

fullURL := c.baseURL + path
log.Printf("fishjam: %s %s body=%s", method, fullURL, string(jsonBody))

req, err := http.NewRequest(method, fullURL, bytes.NewReader(jsonBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.managementToken)

resp, err := c.httpClient.Do(req)
if err != nil {
log.Printf("fishjam: request error for %s %s: %v", method, fullURL, err)
return nil, err
}

respBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("fishjam: failed to read response body for %s %s: %v", method, fullURL, err)
return nil, fmt.Errorf("read response body: %w", err)
}
resp.Body = io.NopCloser(bytes.NewReader(respBody))

log.Printf("fishjam: %s %s -> %d body=%s", method, fullURL, resp.StatusCode, string(respBody))

return resp, nil
}

func readError(resp *http.Response) error {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
}
Loading