Skip to content

Commit 5773fb2

Browse files
authored
Composition SDK (#94)
* Add the generated Composition API client Generated from the Composition API spec, the same way as the Fishjam client. The whip and whep endpoints speak application/sdp, which the generator does not support, so they are absent. * Add CompositionClient Wraps the generated bindings the way FishjamClient wraps its own, with a register method per input and output variant because the response differs per type. Models are exported from fishjam.composition. * Forward a room's tracks into a composition Points Fishjam at a composition and reports the link back on the room. The livestream WHIP and WHEP addresses come with it, since they are derived from the Fishjam URL rather than configured. * Add the composition example A backend that composes a looping movie and a camera published over WHIP into a Fishjam livestream, serving the credentials for both ends over HTTP. * Name both generated clients after the API they wrap Neither _openapi_client nor _composition_client said what the other did. * Report Composition API failures as the server states them A status outside the standard set raised ValueError from the generated client instead of an HTTPError, and messages were wrapped in a list, so they read as ['gone'] where the Fishjam ones read as text. * Start the demo's services with the server Building them at import allocated a room and a composition before startup, and leaked the room when the composition failed.
1 parent e842921 commit 5773fb2

246 files changed

Lines changed: 12303 additions & 64 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎examples/composition/.env.example‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Your Fishjam envs, which you can get at https://fishjam.io/app
2+
FISHJAM_ID="your-fishjam-id"
3+
FISHJAM_MANAGEMENT_TOKEN="your-management-token"
4+
5+
# Only needed when running against a deployment other than production
6+
# COMPOSITION_URL="http://localhost:8000"

‎examples/composition/README.md‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Composition Demo
2+
3+
Demo application showing compositions, the real-time video compositing sessions, with
4+
[Fishjam](https://fishjam.io) and the Python Server SDK.
5+
6+
Two sources are composed into one picture, laid out like a gaming livestream: a looping
7+
movie fills the stage with your camera tucked into its top corner, framed by the Fishjam
8+
logo and a caption bar. The result is sent to a Fishjam livestream that viewers watch.
9+
10+
```
11+
[camera] ── WHIP ─┐
12+
├─▶ composition ── WHIP ─▶ fishjam livestream ── WHEP ─▶ [viewers]
13+
[movie mp4] ──────┘
14+
```
15+
16+
## What it shows
17+
18+
- **Inputs**: a WHIP input the demo prints publishing credentials for, and an MP4 input
19+
looping a movie from a URL
20+
- **Renderers**: an SVG logo registered as an image, and the Inter font used by the caption
21+
- **Scene**: the movie fills the stage with the camera in its top corner, on a cream frame
22+
with a coral bar along the bottom. Each tile sits on a black backing, so a stream that is
23+
not publishing yet reads as an empty tile rather than a hole
24+
- _picture in picture_ — the movie fills the stage, the camera sits in its top corner
25+
- _spotlight_ — the camera takes the stage with the movie tucked away
26+
- _side by side_ — the movie and the camera share the stage
27+
- **Audio**: both inputs mixed, with the movie ducked under the camera
28+
29+
## Prerequisites
30+
31+
- Python 3.10+
32+
- [uv](https://docs.astral.sh/uv/) package manager
33+
- Fishjam credentials ([get them here](https://fishjam.io/app))
34+
35+
> [!IMPORTANT]
36+
> All commands should be run from the `examples/composition` directory
37+
38+
## Quick Start
39+
40+
1. Install dependencies:
41+
42+
```bash
43+
uv sync
44+
```
45+
46+
2. Copy [`.env.example`](./.env.example) to `.env` and populate your environment variables.
47+
48+
3. Run the server:
49+
50+
```bash
51+
uv run ./main.py
52+
```
53+
54+
Starting the server creates the composition and the livestream, then serves two endpoints:
55+
56+
| Endpoint | Returns |
57+
| --- | --- |
58+
| `GET /streamer` | `url` and `token` to publish a camera into the composition over WHIP |
59+
| `GET /viewer` | `url` and `token` to watch the composed result over WHEP |
60+
61+
Publish with any WHIP client, such as `useLivestreamStreamer` from the React client SDK,
62+
and watch with a livestream viewer such as `useLivestreamViewer`.
63+
64+
Press Ctrl+C to delete the composition and the livestream room.
65+
66+
To change the scene while the output is running, call `CompositionClient.update_output`.
67+
An update has to mirror the registration: this output registers both video and audio, so
68+
an update has to carry both.
69+
70+
> [!NOTE]
71+
> A composition holds resources until it is deleted, so let the demo clean up on exit
72+
> rather than killing it.
73+
74+
## Composing a whole room
75+
76+
This demo composes inputs whose IDs it chooses itself. To compose everyone in a room
77+
instead, forward the room's tracks with `FishjamClient.forward_room_tracks` and render
78+
them with a template, since a template decides the layout as peers come and go. Build one
79+
with `npx @fishjam-cloud/composition-cli build App.tsx --out template.js`, register it
80+
with `CompositionClient.register_template_output`, and see the
81+
[JS example](https://github.com/fishjam-cloud/js-server-sdk/tree/main/examples/composition)
82+
for a template to start from.

‎examples/composition/composition/__init__.py‎

Whitespace-only changes.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from contextlib import asynccontextmanager
2+
3+
from starlette.applications import Starlette
4+
from starlette.middleware import Middleware
5+
from starlette.middleware.cors import CORSMiddleware
6+
from starlette.requests import Request
7+
from starlette.responses import JSONResponse, Response
8+
from starlette.routing import Route
9+
10+
from .composition_service import CompositionService
11+
from .config import COMPOSITION_URL, FISHJAM_ID, FISHJAM_TOKEN
12+
from .fishjam_service import FishjamService
13+
14+
15+
def clean_up(*services) -> None:
16+
for service in services:
17+
if service is None:
18+
continue
19+
20+
try:
21+
service.cleanup()
22+
except Exception as error:
23+
print(f"cleanup failed: {error}")
24+
25+
26+
@asynccontextmanager
27+
async def lifespan(app: Starlette):
28+
fishjam = composition = None
29+
30+
try:
31+
fishjam = FishjamService(FISHJAM_ID, FISHJAM_TOKEN)
32+
composition = CompositionService(FISHJAM_TOKEN, COMPOSITION_URL)
33+
composition.register_assets()
34+
composition.play_movie()
35+
composition.camera()
36+
composition.stream_to(
37+
fishjam.livestream_whip_url(), fishjam.create_streamer_token()
38+
)
39+
except Exception:
40+
clean_up(composition, fishjam)
41+
raise
42+
43+
app.state.fishjam = fishjam
44+
app.state.composition = composition
45+
46+
try:
47+
yield
48+
finally:
49+
clean_up(composition, fishjam)
50+
print("deleted the composition and the livestream room")
51+
52+
53+
async def streamer(request: Request) -> Response:
54+
camera = request.app.state.composition.camera()
55+
56+
return JSONResponse({"url": camera.url, "token": camera.bearer_token})
57+
58+
59+
async def viewer(request: Request) -> Response:
60+
fishjam = request.app.state.fishjam
61+
62+
return JSONResponse({
63+
"url": fishjam.livestream_whep_url(),
64+
"token": fishjam.create_viewer_token(),
65+
})
66+
67+
68+
app = Starlette(
69+
lifespan=lifespan,
70+
routes=[
71+
Route("/streamer", streamer, methods=["GET"]),
72+
Route("/viewer", viewer, methods=["GET"]),
73+
],
74+
middleware=[
75+
Middleware(
76+
CORSMiddleware,
77+
allow_origins=["*"],
78+
allow_credentials=True,
79+
allow_methods=["*"],
80+
allow_headers=["*"],
81+
)
82+
],
83+
)
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import httpx
2+
3+
from fishjam import CompositionClient, WhipInputTarget
4+
from fishjam.composition import (
5+
AudioScene,
6+
AudioSceneInput,
7+
ImageSpecSvg,
8+
ImageSpecSvgAssetType,
9+
OutputWhipAudioOptions,
10+
OutputWhipVideoOptions,
11+
Resolution,
12+
)
13+
14+
from .config import (
15+
CAMERA_INPUT_ID,
16+
FONT_URL,
17+
HEIGHT,
18+
LOGO_IMAGE_ID,
19+
LOGO_URL,
20+
MOVIE_INPUT_ID,
21+
MOVIE_URL,
22+
OUTPUT_ID,
23+
WIDTH,
24+
)
25+
from .scene import scene
26+
27+
28+
class CompositionService:
29+
def __init__(self, management_token: str, composition_url: str | None = None):
30+
self.compositions = CompositionClient(
31+
management_token=management_token, composition_url=composition_url
32+
)
33+
self.composition_id = self.compositions.create_composition().composition_id
34+
self._camera: WhipInputTarget | None = None
35+
36+
def register_assets(self) -> None:
37+
self.compositions.register_font(
38+
self.composition_id, httpx.get(FONT_URL, follow_redirects=True).content
39+
)
40+
self.compositions.register_image(
41+
self.composition_id,
42+
LOGO_IMAGE_ID,
43+
ImageSpecSvg(
44+
asset_type=ImageSpecSvgAssetType.SVG,
45+
url=LOGO_URL,
46+
resolution=Resolution(width=200, height=200),
47+
),
48+
)
49+
50+
def play_movie(self) -> None:
51+
self.compositions.register_mp4_input(
52+
self.composition_id, MOVIE_INPUT_ID, url=MOVIE_URL, loop=True
53+
)
54+
55+
def camera(self) -> WhipInputTarget:
56+
if self._camera is None:
57+
self._camera = self.compositions.register_whip_input(
58+
self.composition_id, CAMERA_INPUT_ID, video=True
59+
)
60+
61+
return self._camera
62+
63+
def stream_to(self, endpoint_url: str, bearer_token: str) -> None:
64+
self.compositions.register_whip_output(
65+
self.composition_id,
66+
OUTPUT_ID,
67+
endpoint_url=endpoint_url,
68+
bearer_token=bearer_token,
69+
video=OutputWhipVideoOptions(
70+
resolution=Resolution(width=WIDTH, height=HEIGHT), initial=scene()
71+
),
72+
audio=OutputWhipAudioOptions(
73+
initial=AudioScene(
74+
inputs=[
75+
AudioSceneInput(input_id=CAMERA_INPUT_ID),
76+
AudioSceneInput(input_id=MOVIE_INPUT_ID, volume=0.2),
77+
]
78+
)
79+
),
80+
)
81+
82+
def cleanup(self) -> None:
83+
self.compositions.delete_composition(self.composition_id)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import os
2+
3+
import dotenv
4+
5+
dotenv.load_dotenv()
6+
7+
FISHJAM_ID = os.environ["FISHJAM_ID"]
8+
FISHJAM_TOKEN = os.environ["FISHJAM_MANAGEMENT_TOKEN"]
9+
COMPOSITION_URL = os.getenv("COMPOSITION_URL")
10+
HOST = os.getenv("HOST", "localhost")
11+
PORT = int(os.getenv("PORT", "8000"))
12+
13+
CAMERA_INPUT_ID = "camera"
14+
MOVIE_INPUT_ID = "movie"
15+
LOGO_IMAGE_ID = "fish"
16+
OUTPUT_ID = "livestream"
17+
18+
MOVIE_URL = "https://github.com/smelter-labs/smelter-snapshot-tests/raw/refs/heads/main/assets/BigBuckBunny720p24fpsStereo30s.mp4"
19+
LOGO_URL = "https://fishjam.swmansion.com/favicon.svg"
20+
FONT_URL = "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf"
21+
FONT_FAMILY = "Inter"
22+
23+
WIDTH = 1280
24+
HEIGHT = 720
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from fishjam import FishjamClient, RoomOptions
2+
3+
4+
class FishjamService:
5+
def __init__(self, fishjam_id: str, management_token: str):
6+
self.fishjam = FishjamClient(fishjam_id, management_token)
7+
self.livestream_id = self.fishjam.create_room(
8+
RoomOptions(room_type="livestream")
9+
).id
10+
11+
def livestream_whip_url(self) -> str:
12+
return self.fishjam.livestream_whip_url()
13+
14+
def livestream_whep_url(self) -> str:
15+
return self.fishjam.livestream_whep_url()
16+
17+
def create_streamer_token(self) -> str:
18+
return self.fishjam.create_livestream_streamer_token(self.livestream_id)
19+
20+
def create_viewer_token(self) -> str:
21+
return self.fishjam.create_livestream_viewer_token(self.livestream_id)
22+
23+
def cleanup(self) -> None:
24+
self.fishjam.delete_room(self.livestream_id)

0 commit comments

Comments
 (0)