Skip to content
Open
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
6 changes: 6 additions & 0 deletions examples/composition/.env.example
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
# Your Fishjam envs, which you can get at https://fishjam.io/app
FISHJAM_ID="your-fishjam-id"
FISHJAM_MANAGEMENT_TOKEN="your-management-token"

# Only needed when running against a deployment other than production
# COMPOSITION_URL="http://localhost:8000"
82 changes: 82 additions & 0 deletions examples/composition/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
# Composition Demo

Demo application showing compositions, the real-time video compositing sessions, with
[Fishjam](https://fishjam.io) and the Python Server SDK.

Two sources are composed into one picture, laid out like a gaming livestream: a looping
movie fills the stage with your camera tucked into its top corner, framed by the Fishjam
logo and a caption bar. The result is sent to a Fishjam livestream that viewers watch.

```
[camera] ── WHIP ─┐
├─▶ composition ── WHIP ─▶ fishjam livestream ── WHEP ─▶ [viewers]
[movie mp4] ──────┘
```

## What it shows

- **Inputs**: a WHIP input the demo prints publishing credentials for, and an MP4 input
looping a movie from a URL
- **Renderers**: an SVG logo registered as an image, and the Inter font used by the caption
- **Scene**: the movie fills the stage with the camera in its top corner, on a cream frame
with a coral bar along the bottom. Each tile sits on a black backing, so a stream that is
not publishing yet reads as an empty tile rather than a hole
- _picture in picture_ — the movie fills the stage, the camera sits in its top corner
- _spotlight_ — the camera takes the stage with the movie tucked away
- _side by side_ — the movie and the camera share the stage
- **Audio**: both inputs mixed, with the movie ducked under the camera

## Prerequisites

- Python 3.10+
- [uv](https://docs.astral.sh/uv/) package manager
- Fishjam credentials ([get them here](https://fishjam.io/app))

> [!IMPORTANT]
> All commands should be run from the `examples/composition` directory

## Quick Start

1. Install dependencies:

```bash
uv sync
```

2. Copy [`.env.example`](./.env.example) to `.env` and populate your environment variables.

3. Run the server:

```bash
uv run ./main.py
```

Starting the server creates the composition and the livestream, then serves two endpoints:

| Endpoint | Returns |
| --- | --- |
| `GET /streamer` | `url` and `token` to publish a camera into the composition over WHIP |
| `GET /viewer` | `url` and `token` to watch the composed result over WHEP |

Publish with any WHIP client, such as `useLivestreamStreamer` from the React client SDK,
and watch with a livestream viewer such as `useLivestreamViewer`.

Press Ctrl+C to delete the composition and the livestream room.

To change the scene while the output is running, call `CompositionClient.update_output`.
An update has to mirror the registration: this output registers both video and audio, so
an update has to carry both.

> [!NOTE]
> A composition holds resources until it is deleted, so let the demo clean up on exit
> rather than killing it.

## Composing a whole room

This demo composes inputs whose IDs it chooses itself. To compose everyone in a room
instead, forward the room's tracks with `FishjamClient.forward_room_tracks` and render
them with a template, since a template decides the layout as peers come and go. Build one
with `npx @fishjam-cloud/composition-cli build App.tsx --out template.js`, register it
with `CompositionClient.register_template_output`, and see the
[JS example](https://github.com/fishjam-cloud/js-server-sdk/tree/main/examples/composition)
for a template to start from.
Empty file.
83 changes: 83 additions & 0 deletions examples/composition/composition/app.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
from contextlib import asynccontextmanager

from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.routing import Route

from .composition_service import CompositionService
from .config import COMPOSITION_URL, FISHJAM_ID, FISHJAM_TOKEN
from .fishjam_service import FishjamService


def clean_up(*services) -> None:
for service in services:
if service is None:
continue

try:
service.cleanup()
except Exception as error:
print(f"cleanup failed: {error}")


@asynccontextmanager
async def lifespan(app: Starlette):
fishjam = composition = None

try:
fishjam = FishjamService(FISHJAM_ID, FISHJAM_TOKEN)
composition = CompositionService(FISHJAM_TOKEN, COMPOSITION_URL)
composition.register_assets()
composition.play_movie()
composition.camera()
composition.stream_to(
fishjam.livestream_whip_url(), fishjam.create_streamer_token()
)
except Exception:
clean_up(composition, fishjam)
raise

app.state.fishjam = fishjam
app.state.composition = composition

try:
yield
finally:
clean_up(composition, fishjam)
print("deleted the composition and the livestream room")


async def streamer(request: Request) -> Response:
camera = request.app.state.composition.camera()

return JSONResponse({"url": camera.url, "token": camera.bearer_token})


async def viewer(request: Request) -> Response:
fishjam = request.app.state.fishjam

return JSONResponse({
"url": fishjam.livestream_whep_url(),
"token": fishjam.create_viewer_token(),
})


app = Starlette(
lifespan=lifespan,
routes=[
Route("/streamer", streamer, methods=["GET"]),
Route("/viewer", viewer, methods=["GET"]),
],
middleware=[
Middleware(
CORSMiddleware,
allow_origins=["*"],
Comment thread
Gawor270 marked this conversation as resolved.
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
],
)
83 changes: 83 additions & 0 deletions examples/composition/composition/composition_service.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
import httpx

from fishjam import CompositionClient, WhipInputTarget
from fishjam.composition import (
AudioScene,
AudioSceneInput,
ImageSpecSvg,
ImageSpecSvgAssetType,
OutputWhipAudioOptions,
OutputWhipVideoOptions,
Resolution,
)

from .config import (
CAMERA_INPUT_ID,
FONT_URL,
HEIGHT,
LOGO_IMAGE_ID,
LOGO_URL,
MOVIE_INPUT_ID,
MOVIE_URL,
OUTPUT_ID,
WIDTH,
)
from .scene import scene


class CompositionService:
def __init__(self, management_token: str, composition_url: str | None = None):
self.compositions = CompositionClient(
management_token=management_token, composition_url=composition_url
)
self.composition_id = self.compositions.create_composition().composition_id
self._camera: WhipInputTarget | None = None

def register_assets(self) -> None:
self.compositions.register_font(
self.composition_id, httpx.get(FONT_URL, follow_redirects=True).content
)
self.compositions.register_image(
self.composition_id,
LOGO_IMAGE_ID,
ImageSpecSvg(
asset_type=ImageSpecSvgAssetType.SVG,
url=LOGO_URL,
resolution=Resolution(width=200, height=200),
),
)

def play_movie(self) -> None:
self.compositions.register_mp4_input(
self.composition_id, MOVIE_INPUT_ID, url=MOVIE_URL, loop=True
)

def camera(self) -> WhipInputTarget:
if self._camera is None:
self._camera = self.compositions.register_whip_input(
self.composition_id, CAMERA_INPUT_ID, video=True
)

return self._camera

def stream_to(self, endpoint_url: str, bearer_token: str) -> None:
self.compositions.register_whip_output(
self.composition_id,
OUTPUT_ID,
endpoint_url=endpoint_url,
bearer_token=bearer_token,
video=OutputWhipVideoOptions(
resolution=Resolution(width=WIDTH, height=HEIGHT), initial=scene()
),
audio=OutputWhipAudioOptions(
initial=AudioScene(
inputs=[
AudioSceneInput(input_id=CAMERA_INPUT_ID),
AudioSceneInput(input_id=MOVIE_INPUT_ID, volume=0.2),
]
)
),
)

def cleanup(self) -> None:
self.compositions.delete_composition(self.composition_id)
24 changes: 24 additions & 0 deletions examples/composition/composition/config.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import os

import dotenv

dotenv.load_dotenv()

FISHJAM_ID = os.environ["FISHJAM_ID"]
FISHJAM_TOKEN = os.environ["FISHJAM_MANAGEMENT_TOKEN"]
COMPOSITION_URL = os.getenv("COMPOSITION_URL")
HOST = os.getenv("HOST", "localhost")
PORT = int(os.getenv("PORT", "8000"))

CAMERA_INPUT_ID = "camera"
MOVIE_INPUT_ID = "movie"
LOGO_IMAGE_ID = "fish"
OUTPUT_ID = "livestream"

MOVIE_URL = "https://github.com/smelter-labs/smelter-snapshot-tests/raw/refs/heads/main/assets/BigBuckBunny720p24fpsStereo30s.mp4"
LOGO_URL = "https://fishjam.swmansion.com/favicon.svg"
FONT_URL = "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf"
FONT_FAMILY = "Inter"

WIDTH = 1280
HEIGHT = 720
24 changes: 24 additions & 0 deletions examples/composition/composition/fishjam_service.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
from fishjam import FishjamClient, RoomOptions


class FishjamService:
def __init__(self, fishjam_id: str, management_token: str):
self.fishjam = FishjamClient(fishjam_id, management_token)
self.livestream_id = self.fishjam.create_room(
RoomOptions(room_type="livestream")
).id

def livestream_whip_url(self) -> str:
return self.fishjam.livestream_whip_url()

def livestream_whep_url(self) -> str:
return self.fishjam.livestream_whep_url()

def create_streamer_token(self) -> str:
return self.fishjam.create_livestream_streamer_token(self.livestream_id)

def create_viewer_token(self) -> str:
return self.fishjam.create_livestream_viewer_token(self.livestream_id)

def cleanup(self) -> None:
self.fishjam.delete_room(self.livestream_id)
Loading
Loading