-
Notifications
You must be signed in to change notification settings - Fork 1
Fishjam foundry example #59
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
||
| # Frontend: backend URL | ||
| VITE_BACKEND_URL=http://localhost:8080 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| .env |
| 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. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue:
Suggested change
|
||||||
|
|
||||||
| ## 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. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue: www.smelter.dev |
||||||
| - Go backend with direct HTTP/WebSocket calls (no SDK). | ||||||
| - React + Vite frontend with `@fishjam-cloud/react-client`. | ||||||
| - Tailwind CSS for styling. | ||||||
| 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"] |
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)) | ||
| } |
There was a problem hiding this comment.
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