diff --git a/.gitmodules b/.gitmodules index 6bd7ad0c..f9ec1a7a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -16,3 +16,6 @@ [submodule "packages/python-server-sdk"] path = packages/python-server-sdk url = https://github.com/fishjam-cloud/python-server-sdk.git +[submodule "api/composition"] + path = api/composition + url = git@github.com:fishjam-cloud/foundry.git diff --git a/api/composition b/api/composition new file mode 160000 index 00000000..3031fde0 --- /dev/null +++ b/api/composition @@ -0,0 +1 @@ +Subproject commit 3031fde0e833733456a212f927ecc0073de33158 diff --git a/docs/api/reference.md b/docs/api/reference.md index e10073e1..50fd479d 100644 --- a/docs/api/reference.md +++ b/docs/api/reference.md @@ -6,7 +6,7 @@ type: reference Describes APIs for direct interaction with Fishjam. -Fishjam publishes documentation for the Sandbox API and Fishjam Server APIs. +Fishjam publishes documentation for the Sandbox API, Fishjam Server APIs, and the Composition API. ## Sandbox API @@ -51,3 +51,59 @@ the first message that must be sent is an `AuthRequest`, with a valid Management Token. Next, you can should subscribe to notifications by sending `SubscribeRequest` event with `SERVER_NOTIFICATION` event type. + +## Compositions + +[Compositions](../explanation/compositions) are managed through the Composition API: a REST API plus a WebSocket stream for engine events. All requests go to `https://rtc.fishjam.io`. + +### REST API + +[Composition REST API Reference](/api/compositions) + +The [OpenAPI document](https://github.com/fishjam-cloud/documentation/blob/main/static/api/composition-openapi.json) is generated from the service's source code and republished together with documentation updates. + +Alongside the input, output, and renderer endpoints, the API exposes three lifecycle calls: `POST …/start` starts a composition created with `autostart` off, `POST …/reset` tears down every registered input, output, and renderer and returns the composition to an empty, unstarted state, and `DELETE /api/composition/{composition_id}` destroys it. + +### WebSocket event stream + +Some engine events (for example, an output finishing) are delivered over a WebSocket rather than HTTP. Connect to: + +``` +GET wss://rtc.fishjam.io/api/composition/{composition_id}/ws +``` + +Because browsers cannot set an `Authorization` header on a WebSocket, authentication rides on the `Sec-WebSocket-Protocol` header, which must carry **two** subprotocols (order does not matter): + +- `json.fishjam.io`: selects the JSON wire format. +- `bearer.auth.fishjam.io.`: your token, appended to the literal prefix. + +```js +const ws = new WebSocket( + `wss://rtc.fishjam.io/api/composition/${compositionId}/ws`, + ["json.fishjam.io", `bearer.auth.fishjam.io.${token}`], +); +``` + +Messages are JSON text frames, each with a `type` field identifying the event. + +### Composition authentication + +| What you're calling | How it authenticates | +| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Control-plane calls (composition, inputs, outputs, renderers, events, room) | `Authorization: Bearer `: your Fishjam **Management Token**, the same token used across Fishjam. | +| Publishing to an input (`/whip/{input_id}`) | The **input's** own bearer token, returned when you register a `whip_server` input (or the one you supplied). This is distinct from your account token. | +| The WebSocket event stream | The subprotocol scheme described above. | + +Get your Management Token from the [**Fishjam developer panel**](https://fishjam.io/app). + +Compositions are not played back from the Composition API itself: outputs push to the destination you register, so viewers connect to that destination instead. A `whip_client` output pointed at a [livestream](../explanation/livestreams), for example, is watched through the livestream's own WHEP endpoint. + +### Errors + +Every non-2xx response of the Composition API is a JSON object: + +```json +{ "message": "Composition not found", "http_status_code": 404 } +``` + +Common statuses are `400` (bad request), `401` (unauthorized), `404` (not found), `500` (server error), and `503` (no capacity, returned by composition creation, templated output registration, events, and the WebSocket stream). diff --git a/docs/explanation/compositions.mdx b/docs/explanation/compositions.mdx new file mode 100644 index 00000000..7a9e5104 --- /dev/null +++ b/docs/explanation/compositions.mdx @@ -0,0 +1,110 @@ +--- +type: explanation +title: Compositions +sidebar_position: 4.5 +description: Understand compositions, the Fishjam feature that mixes multiple live media streams into a single composed output in real time. +--- + +# Compositions + +_Understanding real-time stream composition in Fishjam_ + +A **composition** mixes multiple live media streams into a single output stream in real time. You send inputs (WebRTC, RTMP, HLS, or MP4), describe how they should be laid out, and it produces one composed output that you can publish anywhere, all without running any rendering infrastructure yourself. + +Compositions are built on [Smelter](https://smelter.dev), the open-source real-time video compositing engine by [Software Mansion](https://swmansion.com), and are a native part of Fishjam: they authenticate with the same Fishjam credentials and can compose the peers of a [Fishjam room](./rooms) directly. + +## What you can build + +- **Multi-party layouts**: arrange the cameras of a conference or livestream into grids, side-by-sides, or picture-in-picture. +- **Branded streams**: overlay logos, captions, lower-thirds, and backgrounds on top of live video. +- **Cross-protocol bridging**: take WebRTC inputs and republish the composed result to an RTMP destination, or vice versa. + +## Core concepts + +A **composition** is a single running compositing session. Into it you register: + +- **Inputs**: the live media sources being composed (WebRTC via WHIP/WHEP, RTMP, HLS, or MP4). +- **Outputs**: where the composed result is sent (WebRTC via WHIP, or RTMP). Each output carries a **scene** that describes the layout. +- **Renderers**: shared assets such as images and fonts you can place in a scene. + +An output's scene can either be described directly in the API or rendered by a **template**: a React component, written with the layout components and the [`@fishjam-cloud/composition`](./../how-to/compositions/write-and-deploy-a-template) hooks, that updates the layout live as the room changes. + +## Cost and lifecycle + +A running composition holds a GPU-backed rendering session for as long as it exists, and you are billed for that time whether or not anyone is watching. It is the most expensive thing in this part of Fishjam to leave running by accident. + +Two defaults keep that in check. A composition auto-starts, and it cleans itself up after five minutes in which none of its inputs carry any media. Between them, an experiment you walk away from stops costing you money on its own. + +`cleanup_without_inputs: false` turns that guard off. It widens the condition so cleanup needs both the inputs and the outputs to go quiet, which is what you want when inputs arrive late, such as a room whose peers have not joined yet, or when a composition legitimately has no inputs, such as an output that renders only text. The cost is that an idle composition then survives indefinitely, so anything created that way is yours to delete, and a forgotten one bills until you do. + +Delete a composition as soon as you are done with it: + +```bash +curl -X DELETE "$COMPOSITION_URL/api/composition/$COMPOSITION" \ + -H "Authorization: Bearer $TOKEN" +``` + +## Scenes + +Every video output carries a **scene**: a tree of components that describes how inputs, text, and images are arranged into the composed frame. Audio outputs carry an **audio scene** that describes which inputs are mixed together. + +### The video scene tree + +A video scene has a single `root` component. Each component has a `type` that determines how it lays out its children. + +```json +{ + "root": { + "type": "tiles", + "children": [ + { "type": "input_stream", "input_id": "camera_1" }, + { "type": "input_stream", "input_id": "camera_2" } + ] + } +} +``` + +The available component types are: + +| `type` | Purpose | +| -------------- | -------------------------------------------------------------------------------- | +| `input_stream` | Renders one registered input. Identified by `input_id`. | +| `view` | A container you position and style; the basic building block for custom layouts. | +| `tiles` | Automatically arranges its children into a grid. | +| `rescaler` | Fits a single child into a target area, preserving aspect ratio. | +| `text` | Renders a text string. | +| `image` | Renders a registered image. Identified by `image_id`. | + +Components nest freely: a `tiles` of `rescaler`s wrapping `input_stream`s, a `view` with a `text` caption over an `input_stream`, and so on. The styling and full property set of each component come from Smelter itself; the [Smelter HTTP API reference](https://smelter.dev/http-api/overview) documents every component and its properties. + +### The audio scene + +An audio scene lists the inputs to mix and, optionally, their relative volume: + +```json +{ + "inputs": [ + { "input_id": "camera_1" }, + { "input_id": "camera_2", "volume": 0.5 } + ] +} +``` + +`volume` defaults to `1.0`. Only the inputs you list are audible in the output. + +### Setting a scene + +You provide the initial scene when you register an output, under `video.initial` (a video scene) and `audio.initial` (an audio scene). See [Choose inputs and outputs](./../how-to/compositions/inputs-and-outputs) for the full output shape. + +### Changing a scene over time + +A scene is not fixed for the life of an output. You can replace it while the composition is running, immediately or at a chosen point on the composition timeline. + +Either you push those updates yourself, or you hand the job to a **template**: a React component that receives the live room state and re-renders as the room changes. See [Choose inputs and outputs](./../how-to/compositions/inputs-and-outputs) to send an update, or [Write and deploy a template](./../how-to/compositions/write-and-deploy-a-template) to build one. + +## Where to go next + +- [Compositions tutorial](./../tutorials/compositions): create your first composition end to end. +- [Write and deploy a template](./../how-to/compositions/write-and-deploy-a-template): build a React layout with the composition SDK. +- [Compose a Fishjam room](./../how-to/compositions/compose-a-fishjam-room): turn a room's peers into one composed stream. +- [Composition API](./../api/reference#compositions): the full REST surface. diff --git a/docs/how-to/compositions/_category_.json b/docs/how-to/compositions/_category_.json new file mode 100644 index 00000000..f0331dc1 --- /dev/null +++ b/docs/how-to/compositions/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "Compositions", + "position": 5, + "link": { + "type": "generated-index", + "description": "Compose rooms and live streams into new outputs with templates, events, and multiple transports." + } +} diff --git a/docs/how-to/compositions/compose-a-fishjam-room.mdx b/docs/how-to/compositions/compose-a-fishjam-room.mdx new file mode 100644 index 00000000..8ab37c18 --- /dev/null +++ b/docs/how-to/compositions/compose-a-fishjam-room.mdx @@ -0,0 +1,181 @@ +--- +type: how-to +sidebar_position: 2 +description: Forward a Fishjam room into a composition and render its peers with a live React template. +--- + +# Compose a Fishjam room + +A composition can include the peers of a [Fishjam room](./../../explanation/rooms). 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](./write-and-deploy-a-template) render one tile per participant and push the result to a [livestream](./../../explanation/livestreams). + +``` +Fishjam room (peers) ──forwarded──▶ composition (template) ──WHIP──▶ Fishjam livestream ──WHEP──▶ [viewers] +``` + +## Prerequisites + +- A [livestream](./../../explanation/livestreams) (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](./write-and-deploy-a-template)). + +Compositions live on `https://rtc.fishjam.io`, while rooms and livestreams live on the Fishjam API. Both take the same Management Token: + +```bash +export COMPOSITION_URL="https://rtc.fishjam.io" +export FISHJAM_URL="https://fishjam.io/api/v1/connect/" +export 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: + +```bash +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: + +```bash +export ROOM_ID="" +``` + +Every participant needs their own peer token. Create one per person: + +```bash +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](./../client/connecting) or the [React quick start](./../../tutorials/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. + +```tsx +// @jsx: react-jsx +// ---cut-before--- +import { InputStream, Rescaler, Text, Tiles, View } from "@swmansion/smelter"; +import { usePeers, useSpeakingState } from "@fishjam-cloud/composition"; +import type { PeerWithStreams } from "@fishjam-cloud/composition"; + +type PeerMetadata = { displayName?: string }; + +function PeerTile({ peer }: { peer: PeerWithStreams }) { + const camera = peer.cameraStream; + const cameraOn = camera?.video && !camera.video.paused; + const speaking = useSpeakingState(peer.id) === "speech"; + const name = peer.metadata.peer?.displayName ?? peer.id; + + return ( + + {cameraOn ? ( + + + + ) : ( + + {name} + + )} + + ); +} + +export default function App() { + const peers = usePeers(); + const connected = peers.filter((peer) => peer.streams.length > 0); + + return ( + + + {connected.map((peer) => ( + + ))} + + + ); +} +``` + +| 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 `` 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` widens the cleanup condition to require both the inputs and the outputs to go quiet, so the composition survives the wait for the first peer to publish. + +:::warning +That second flag switches off the guard that would otherwise delete an idle composition after five minutes, and a composition bills for GPU time the whole time it exists. Nothing will clean this one up for you, so delete it as soon as you are finished, and do not leave one running after a test. See [Cost and lifecycle](./../../explanation/compositions#cost-and-lifecycle). +::: + +```bash +curl -X POST "$COMPOSITION_URL/api/composition" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ "autostart": false, "cleanup_without_inputs": false }' +``` + +Register a templated `whip_client` output that pushes to your livestream's WHIP endpoint. The output configuration and the template bundle go together in one multipart request: + +```bash +curl -X POST "$COMPOSITION_URL/api/composition/$COMPOSITION/output/main/template" \ + -H "Authorization: Bearer $TOKEN" \ + -F 'config={ + "type": "whip_client", + "endpoint_url": "", + "bearer_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 the [Fishjam Server API](/api/rest) wires everything up: + +```bash +curl -X POST "$FISHJAM_URL/room/$ROOM_ID/track_forwardings" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ \"compositionURL\": \"$COMPOSITION_URL/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 forwards to one composition at a time. Repeating the call with the same `compositionURL` is a no-op, while pointing the room at a different composition fails with `409 Conflict`. + +## Step 5: Start the composition + +```bash +curl -X POST "$COMPOSITION_URL/api/composition/$COMPOSITION/start" \ + -H "Authorization: Bearer $TOKEN" +``` + +Viewers can now watch the composed grid through the livestream's WHEP endpoint. + +## 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. + +```bash +curl -X DELETE "$COMPOSITION_URL/api/composition/$COMPOSITION" \ + -H "Authorization: Bearer $TOKEN" + +curl -X DELETE "$FISHJAM_URL/room/$ROOM_ID" \ + -H "Authorization: Bearer $TOKEN" +``` diff --git a/docs/how-to/compositions/drive-a-template-with-events.mdx b/docs/how-to/compositions/drive-a-template-with-events.mdx new file mode 100644 index 00000000..9e09c533 --- /dev/null +++ b/docs/how-to/compositions/drive-a-template-with-events.mdx @@ -0,0 +1,90 @@ +--- +type: how-to +sidebar_position: 3 +description: Send custom events to a running composition to update a template's on-screen state at runtime. +--- + +# Drive a template with events + +Once a [template](./write-and-deploy-a-template) is running, you can push **custom events** to it from your backend to change what it shows, such as toggling a live badge or updating a caption, without re-uploading the template. + +A caption-only template registers no inputs, and a composition with no input media cleans itself up after five minutes. Create this one with `cleanup_without_inputs` set to `false` so it stays up while you experiment, instead of disappearing mid-test and leaving playback returning `404 Stream not found`: + +```bash +curl -X POST "$COMPOSITION_URL/api/composition" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ "cleanup_without_inputs": false }' +``` + +:::warning +That flag switches off the guard that would otherwise delete an idle composition after five minutes, and a composition bills for GPU time the whole time it exists. Nothing will clean this one up for you, so delete it as soon as you are finished. See [Cost and lifecycle](./../../explanation/compositions#cost-and-lifecycle). +::: + +## Handle events in the template + +Inside a template, subscribe to events with the `eventBus`. Each `on` call returns an unsubscribe function. Event names and payloads are entirely defined by your application. + +```tsx +// @jsx: react-jsx +// ---cut-before--- +import { useEffect, useState } from "react"; +import { Text, View } from "@swmansion/smelter"; +import { eventBus } from "@fishjam-cloud/composition"; + +export default function App() { + const [caption, setCaption] = useState(null); + const [live, setLive] = useState(false); + + useEffect(() => { + const unsubs = [ + eventBus.on<{ text: string }>("SET_CAPTION", ({ text }) => + setCaption(text), + ), + eventBus.on("CLEAR_CAPTION", () => setCaption(null)), + eventBus.on<{ live: boolean }>("SET_LIVE", ({ live }) => setLive(live)), + ]; + return () => unsubs.forEach((off) => off()); + }, []); + + return ( + + {live && ● LIVE} + {caption && {caption}} + + ); +} +``` + +## Send events from your backend + +Send an event with `POST /api/composition/{composition_id}/event`. The `event_name` is matched against your `eventBus.on(...)` subscriptions, and `data` is delivered as the handler's argument. + +```bash +curl -X POST "$COMPOSITION_URL/api/composition/$COMPOSITION/event" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ "event_name": "SET_CAPTION", "data": { "text": "Welcome to the stream" } }' +``` + +```bash +curl -X POST "$COMPOSITION_URL/api/composition/$COMPOSITION/event" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ "event_name": "SET_LIVE", "data": { "live": true } }' +``` + +The template re-renders as soon as the event arrives. + +:::note +Event names and payloads are an application-defined contract between your backend and your template. There is no canonical set of events; `SET_LIVE` and `SET_CAPTION` above are just examples. Pick names and payload shapes that fit your app, and keep them in sync on both sides. +::: + +## Clean up + +Delete the composition when you have finished testing, so it stops billing for GPU time: + +```bash +curl -X DELETE "$COMPOSITION_URL/api/composition/$COMPOSITION" \ + -H "Authorization: Bearer $TOKEN" +``` diff --git a/docs/how-to/compositions/inputs-and-outputs.mdx b/docs/how-to/compositions/inputs-and-outputs.mdx new file mode 100644 index 00000000..898f8606 --- /dev/null +++ b/docs/how-to/compositions/inputs-and-outputs.mdx @@ -0,0 +1,131 @@ +--- +type: how-to +sidebar_position: 4 +description: Choose the right input and output protocols for a composition, from WebRTC to RTMP, HLS, and MP4. +--- + +# Choose inputs and outputs + +A composition pulls media in through **inputs** and pushes the composed result out through **outputs**. Each is a tagged object whose `type` selects the protocol. This guide summarizes the available types and when to use each. + +## Inputs + +Register an input with `POST /api/composition/{composition_id}/input/{input_id}/register` and a body whose `type` is one of: + +| `type` | Use it when | Key fields | +| ------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `whip_server` | A WebRTC client should publish **into** the composition. | `bearer_token` (optional; generated and returned if omitted), `video` (optional, default `true`). | +| `whep_client` | The composition should **pull** a WebRTC stream from a WHEP endpoint. | `endpoint_url` (required), `bearer_token` (optional), `video` (optional). | +| `rtmp_server` | An encoder (OBS, hardware) should push RTMP in. | `app` (required), `stream_key` (required). | +| `hls` | The composition should pull an HLS playlist. | `url` (required). | +| `mp4` | You want to compose a file, optionally looped. | `url` (optional), `loop` (optional). | + +For `whip_server`, the register response returns the `bearer_token` a publisher uses to authenticate against the input's WHIP endpoint, together with the route to push to: + +```json +{ "bearer_token": "", "endpoint_route": "/whip/camera_1" } +``` + +That token is the only one accepted on the publish endpoint. Your Management Token is rejected there with `401`. + +Point any WHIP publisher at `endpoint_route` under the composition's base URL and authenticate with that token. A `whip_server` input takes WebRTC from anything that speaks WHIP: a phone's camera, a laptop webcam, a browser tab, a screen share, or a hardware encoder. [vdo.ninja](https://vdo.ninja/whip) publishes from a browser, and OBS has WHIP output built in. + +Unregister any input with `POST …/input/{input_id}/unregister`. + +## Outputs + +Register an output with `POST /api/composition/{composition_id}/output/{output_id}/register` and a body whose `type` is one of: + +| `type` | Use it when | Key fields | +| ------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------- | +| `whip_client` | Publish the composed result over WebRTC (for example to a Fishjam livestream). | `endpoint_url` (required), `bearer_token` (optional), `video`, `audio`. | +| `rtmp_client` | Publish to an RTMP destination (for example a social platform). | `url` (required), `video`, `audio`. | + +An output's `video` carries the resolution and initial [scene](./../../explanation/compositions#scenes), and `audio` carries the initial audio scene: + +```json +{ + "type": "whip_client", + "endpoint_url": "https://example.com/whip", + "video": { + "resolution": { "width": 1280, "height": 720 }, + "initial": { "root": { "type": "tiles", "children": [] } } + }, + "audio": { "initial": { "inputs": [] } } +} +``` + +Other output operations: + +- **Register a templated output** with `POST …/output/{output_id}/template` instead of `…/register`: a multipart request carrying the same configuration plus a template bundle (see [Write and deploy a template](./write-and-deploy-a-template)). +- **Update the scene** live with `POST …/output/{output_id}/update` (see [Update a scene](#update-a-scene) below). +- **Force a keyframe** with `POST …/output/{output_id}/request_keyframe`, useful when a new subscriber joins. +- **Unregister** with `POST …/output/{output_id}/unregister`. + +## Update a scene + +Replace an output's [scene](./../../explanation/compositions#scenes) while the composition is running with `POST …/output/{output_id}/update`: + +```bash +curl -X POST "$COMPOSITION_URL/api/composition/$COMPOSITION/output/main/update" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "video": { + "root": { + "type": "rescaler", + "child": { "type": "input_stream", "input_id": "camera_1" } + } + }, + "audio": { "inputs": [{ "input_id": "camera_1" }] } + }' +``` + +An update has to mirror the sides the output was registered with. If you registered the output with both `video` and `audio`, every update must carry both, even when only one of them changed. If you registered only one of them, the update may only carry that one. Sending a mismatched update fails with `400`. + +Add `schedule_time_ms` to apply the change at a chosen offset on the composition timeline, in milliseconds, instead of immediately: + +```json +{ + "video": { "root": { "type": "tiles", "children": [] } }, + "audio": { "inputs": [] }, + "schedule_time_ms": 5000 +} +``` + +## Renderers + +Renderers are shared assets you register once and then place in any scene. + +### Images + +Register an image with `POST …/image/{image_id}/register`: + +```bash +curl -X POST "$COMPOSITION_URL/api/composition/$COMPOSITION/image/logo/register" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ "asset_type": "auto", "url": "https://example.com/logo.png" }' +``` + +`asset_type` selects the format: `png`, `jpeg`, `svg`, `gif`, or `auto` to detect it from the URL. Each takes a `url`; `svg` also accepts a `resolution`. Place the image in a scene with an `image` component: + +```json +{ "type": "image", "image_id": "logo" } +``` + +Unregister with `POST …/image/{image_id}/unregister`. + +### Fonts + +Register a font with a multipart request carrying the font file in a single `font` part: + +```bash +curl -X POST "$COMPOSITION_URL/api/composition/$COMPOSITION/font/register" \ + -H "Authorization: Bearer $TOKEN" \ + -F "font=@BrandFont.ttf" +``` + +The font is then available to `text` components. + +See the [Composition API](./../../api/reference#compositions) reference for the complete request and response schemas. diff --git a/docs/how-to/compositions/write-and-deploy-a-template.mdx b/docs/how-to/compositions/write-and-deploy-a-template.mdx new file mode 100644 index 00000000..1753f8b5 --- /dev/null +++ b/docs/how-to/compositions/write-and-deploy-a-template.mdx @@ -0,0 +1,174 @@ +--- +type: how-to +sidebar_position: 1 +description: Write a React composition template, bundle it with the CLI, and deploy it as an output. +--- + +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; + +# Write and deploy a template + +A **template** is a React component that describes an output's layout. Instead of sending a static scene, you write a component, bundle it into a single file with the composition CLI, and upload it when registering an output. + +## Scaffold a project + +Create a new template project with the CLI: + +```bash npm2yarn +npx @fishjam-cloud/composition-cli init my-template +cd my-template +npm install +``` + +This generates a ready-to-build project: a `package.json` with `build` and `typecheck` scripts, a TypeScript config, and a starter `src/App.tsx`. + +## Write the template + +A template **default-exports a React component**. You lay out the composition with components from [`@swmansion/smelter`](https://smelter.dev/ts-sdk/overview). + +The example below builds a grid that maintains itself. `useInputStreams()` returns every input currently registered on the composition, so the layout follows them without you sending a single scene update: + +```tsx +// @jsx: react-jsx +// ---cut-before--- +import { + InputStream, + Rescaler, + Text, + Tiles, + View, + useInputStreams, +} from "@swmansion/smelter"; + +export default function App() { + const inputs = Object.values(useInputStreams()); + const playing = inputs.filter((input) => input.videoState === "playing"); + + if (playing.length === 0) { + return ( + + + Waiting for inputs + + + ); + } + + return ( + + + {playing.map((input) => ( + + + + ))} + + + ); +} +``` + +Register an input and a tile appears; unregister it and the grid reflows. Each entry also carries `videoState` and `audioState`, one of `ready`, `playing`, `paused`, or `finished`, which is what the filter above uses to keep inputs off screen until their media actually starts. + +`` renders one of the composition's registered inputs. Layout, styling, and every visual component (`View`, `Tiles`, `InputStream`, `Rescaler`, `Text`, `Image`, …) come from `@swmansion/smelter`.see the [Smelter TypeScript SDK reference](https://smelter.dev/ts-sdk/overview) for every component and its style props. + +## Build the bundle + +```bash npm2yarn +npm run build +``` + +## Deploy the template + +A templated output is registered in a **single multipart request** to `POST …/output/{output_id}/template`. Do not call the plain `…/register` endpoint first. The request carries two parts: + +- `config`: the same JSON body a regular output registration takes (see [Choose inputs and outputs](./inputs-and-outputs)). Its `video.initial` scene is only a placeholder; the template takes over rendering as soon as it loads. +- `template`: the built bundle. + +`endpoint_url` is wherever the composed stream should go. The quickest destination to watch is a Fishjam livestream, exactly as in [the tutorial](./../../tutorials/compositions#step-3-create-a-livestream-to-publish-to): create one, take its streamer token, and publish to `https://fishjam.io/api/v1/live/api/whip`. A public livestream then plays back in any WHEP player without a token. + + + + +```bash +CONFIG=$(cat < + + +```js +const config = { + type: "whip_client", + endpoint_url: "https://fishjam.io/api/v1/live/api/whip", + bearer_token: streamerToken, + video: { + resolution: { width: 1280, height: 720 }, + initial: { root: { type: "view" } }, + }, + audio: { initial: { inputs: [] } }, +}; + +const form = new FormData(); +form.append("config", JSON.stringify(config)); +form.append("template", new Blob([bundle]), "App.js"); + +await fetch( + `${COMPOSITION_URL}/api/composition/${composition}/output/main/template`, + { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: form, + }, +); +``` + + + + +## Redeploy after an edit + +Unregister the output first, then deploy again: + +```bash +curl -X POST "$COMPOSITION_URL/api/composition/$COMPOSITION/output/main/unregister" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +Rebuild with `npm run build` and repeat the deploy request. The inputs stay registered, so only the layout changes. + +## Clean up + +Deleting the composition removes its inputs and outputs with it, so a finished experiment takes two calls: + +```bash +curl -X DELETE "$COMPOSITION_URL/api/composition/$COMPOSITION" \ + -H "Authorization: Bearer $TOKEN" + +curl -X DELETE "$FISHJAM_URL/livestream/$STREAM" \ + -H "Authorization: Bearer $TOKEN" +``` + +## Next steps + +To feed a whole Fishjam room's peers into the template, continue with [Compose a Fishjam room](./compose-a-fishjam-room). To update the template's on-screen state at runtime, see [Drive a template with events](./drive-a-template-with-events). diff --git a/docs/tutorials/compositions.mdx b/docs/tutorials/compositions.mdx new file mode 100644 index 00000000..53147506 --- /dev/null +++ b/docs/tutorials/compositions.mdx @@ -0,0 +1,242 @@ +--- +type: tutorial +title: Compositions +sidebar_position: 3.5 +description: Create your first composition end to end, overlaying one video on another and watching the composed result in your browser. +--- + +# Compositions + +This tutorial walks you through creating your first composition. You will overlay a small player camera on top of full-screen gameplay, publish the result to a livestream, and watch it in your browser. + +Both sources are sample MP4 files, so you need no camera, no encoder, and no publishing tool to get a picture. Nothing else in the tutorial depends on that, so you can swap either one for a live camera afterwards. + +## What you'll build + +``` +[race.mp4] ──┐ + ├─▶ composition ──WHIP──▶ livestream ──WHEP──▶ [your browser] +[player.mp4] ──┘ +``` + +The composed frame puts the race full-screen with the player inset in the top-left corner, standard gaming stream layout: + +``` +┌─────────────────────────────┐ +│ ┌────────┐ │ +│ │ player │ │ +│ └────────┘ race │ +│ │ +└─────────────────────────────┘ +``` + +## What you'll learn + +- How to create a composition and feed it media. +- How to describe a layout as a scene. +- How to publish the composed result and watch it. +- How to change the layout while the composition is running. + +## Prerequisites + +- A Fishjam account. Open the [**Fishjam developer panel**](https://fishjam.io/app) and copy your **Fishjam ID** and **Management Token**. +- A browser, to watch the result. + +Compositions live on `https://rtc.fishjam.io`, while rooms and livestreams live on the Fishjam API. Both use the same token: + +```bash +export COMPOSITION_URL="https://rtc.fishjam.io" +export FISHJAM_URL="https://fishjam.io/api/v1/connect/" +export TOKEN="" +``` + +## Step 1: Create a composition + +```bash +curl -X POST "$COMPOSITION_URL/api/composition" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +The response contains the `composition_id` you will use for every subsequent call: + +```json +{ "composition_id": "abc123", "api_url": "https://rtc.fishjam.io" } +``` + +By default a composition auto-starts, and cleans itself up after five minutes in which none of its inputs carry any media. Save the id: + +```bash +export COMPOSITION="abc123" +``` + +## Step 2: Register two inputs + +Register two `mp4` inputs, looping so they never run dry. The composition downloads them itself, so there is nothing to publish: + +```bash +curl -X POST "$COMPOSITION_URL/api/composition/$COMPOSITION/input/race/register" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "mp4", + "url": "https://smelter.dev/videos/template-scene-race.mp4", + "loop": true + }' + +curl -X POST "$COMPOSITION_URL/api/composition/$COMPOSITION/input/player/register" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "mp4", + "url": "https://smelter.dev/videos/template-scene-streamer.mp4", + "loop": true + }' +``` + +The names `race` and `player` are the `input_id`s you will refer to from the layout in Step 4. The scene only ever refers to inputs by id, so what sits behind an id is interchangeable: register `whip_server` instead of `mp4` to take a live camera, phone, or OBS feed here, or forward a whole [Fishjam room](../how-to/compositions/compose-a-fishjam-room) in. See [Choose inputs and outputs](../how-to/compositions/inputs-and-outputs) for every input type. + +:::note +The file is fetched while the input is being registered, so an unreachable URL fails right here rather than later. +::: + +## Step 3: Create a livestream to publish to + +The composition needs somewhere to send the composed stream. Create a [livestream](./../explanation/livestreams) for it. Making it public lets viewers watch without a token, which keeps this tutorial short: + +```bash +curl -X POST "$FISHJAM_URL/livestream" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ "public": true }' +``` + +Save the id from the response: + +```bash +export STREAM="" +``` + +Then create a **streamer token**, which is what lets the composition publish into the livestream: + +```bash +curl -X POST "$FISHJAM_URL/livestream/$STREAM/streamer" \ + -H "Authorization: Bearer $TOKEN" +``` + +```bash +export STREAMER_TOKEN="" +``` + +## Step 4: Register an output with a layout + +Register a `whip_client` output that publishes to the livestream's WHIP endpoint using the streamer token from Step 3. Its scene stacks two components: the race filling the frame, and the player rescaled into a rounded box in the top-left corner. + +:::note +The output connects to `endpoint_url` while it is being registered, so the endpoint has to be reachable already. That is why the livestream comes first. Registering against a URL that does not accept the connection fails. +::: + +```bash +curl -X POST "$COMPOSITION_URL/api/composition/$COMPOSITION/output/main/register" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d @- < +``` + +If you have no WHEP player to hand, [vdo.ninja](https://vdo.ninja/whip#play) works in the browser: paste that URL into **WHEP endpoint URL** and press **GO**. You should see the race running full-screen with the player tucked into the top-left corner. + +## Step 6: Change the layout while it runs + +A scene is not fixed once the output is registered. Leave the player watching and swap the corner inset for a side-by-side grid: + +```bash +curl -X POST "$COMPOSITION_URL/api/composition/$COMPOSITION/output/main/update" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "video": { + "root": { + "type": "tiles", + "children": [ + { "type": "input_stream", "input_id": "race" }, + { "type": "input_stream", "input_id": "player" } + ] + } + }, + "audio": { "inputs": [{ "input_id": "player" }] } + }' +``` + +The layout changes on the fly, with no interruption to the stream. Send the Step 4 scene again to go back to the inset. + +:::warning +An update has to carry the same sides the output was registered with. This output has both `video` and `audio`, so every update needs both, even when only the layout changed. Sending only one of them fails. See [Update a scene](../how-to/compositions/inputs-and-outputs#update-a-scene) for the full rule. +::: + +## Step 7: Clean up + +Delete the composition and the livestream when you are done: + +```bash +curl -X DELETE "$COMPOSITION_URL/api/composition/$COMPOSITION" \ + -H "Authorization: Bearer $TOKEN" + +curl -X DELETE "$FISHJAM_URL/livestream/$STREAM" \ + -H "Authorization: Bearer $TOKEN" +``` + +## Describing the layout in React instead + +Updating the scene by hand gets tedious once people are joining, leaving, muting, and unmuting, since each of those needs its own call. + +A **template** is a React component that receives the live room state and re-renders itself as the room changes, so you send no scene updates at all. See [Write and deploy a template](../how-to/compositions/write-and-deploy-a-template) and [Compose a Fishjam room](../how-to/compositions/compose-a-fishjam-room). + +## Next steps + +- [Write and deploy a template](../how-to/compositions/write-and-deploy-a-template) to replace the static scene with a live React layout. +- [Compose a Fishjam room](../how-to/compositions/compose-a-fishjam-room) to feed a whole room's peers in automatically. +- [Choose inputs and outputs](../how-to/compositions/inputs-and-outputs) for other protocols like RTMP and HLS. diff --git a/docusaurus.config.ts b/docusaurus.config.ts index cf709720..fa61516f 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -75,71 +75,10 @@ function injectTypeDocSidebar( ): NormalizedSidebar { return items.map((item) => { if (item.customProps?.id === "generated-api" && item.type === "category") { - const injectedItems: (CustomInjectedCategory | NormalizedSidebarItem)[] = - [ - { - type: "category", - label: "React Native SDK", - link: { type: "doc", id: "api/mobile/index" }, - items: require( - `${version.contentPath}/api/mobile/typedoc-sidebar.cjs`, - ), - }, - { - type: "category", - label: "React SDK", - link: { type: "doc", id: "api/web/index" }, - items: require( - `${version.contentPath}/api/web/typedoc-sidebar.cjs`, - ), - }, - { - type: "category", - label: "Server SDK for JS", - link: { type: "doc", id: "api/server/index" }, - items: require( - `${version.contentPath}/api/server/typedoc-sidebar.cjs`, - ), - }, - ]; - - // The custom-source packages ship after 0.28, so older versioned docs - // don't contain their generated API trees — inject only when present. - const customSourceCategories: ( - | CustomInjectedCategory - | NormalizedSidebarItem - )[] = []; - for (const { label, dir } of [ - { label: "Vision Camera Source", dir: "vision-camera-source" }, - { label: "Custom Video Source", dir: "custom-video-source" }, - ]) { - const sidebarModule = `${version.contentPath}/api/${dir}/typedoc-sidebar.cjs`; - if (fs.existsSync(sidebarModule)) { - customSourceCategories.push({ - type: "category", - label, - link: { type: "doc", id: `api/${dir}/index` }, - items: require(sidebarModule), - }); - } - } - injectedItems.splice(1, 0, ...customSourceCategories); - - injectedItems.push({ - type: "category", - label: "Server SDK for Python", - items: [ - { - type: "autogenerated", - dirName: "api/server-python", - }, - ], - }); - return { ...item, items: [ - ...injectedItems, + ...buildInjectedApiItems(version), ...item.items.filter((element) => element.type === "doc"), ] as NormalizedSidebar, }; @@ -149,6 +88,64 @@ function injectTypeDocSidebar( }); } +function buildInjectedApiItems(version: SidebarItemsGeneratorVersion) { + const injectedItems: (CustomInjectedCategory | NormalizedSidebarItem)[] = [ + { + type: "category", + label: "React Native SDK", + link: { type: "doc", id: "api/mobile/index" }, + items: require(`${version.contentPath}/api/mobile/typedoc-sidebar.cjs`), + }, + { + type: "category", + label: "React SDK", + link: { type: "doc", id: "api/web/index" }, + items: require(`${version.contentPath}/api/web/typedoc-sidebar.cjs`), + }, + { + type: "category", + label: "Server SDK for JS", + link: { type: "doc", id: "api/server/index" }, + items: require(`${version.contentPath}/api/server/typedoc-sidebar.cjs`), + }, + ]; + + // The custom-source packages ship after 0.28, so older versioned docs + // don't contain their generated API trees — inject only when present. + const customSourceCategories: ( + | CustomInjectedCategory + | NormalizedSidebarItem + )[] = []; + for (const { label, dir } of [ + { label: "Vision Camera Source", dir: "vision-camera-source" }, + { label: "Custom Video Source", dir: "custom-video-source" }, + ]) { + const sidebarModule = `${version.contentPath}/api/${dir}/typedoc-sidebar.cjs`; + if (fs.existsSync(sidebarModule)) { + customSourceCategories.push({ + type: "category", + label, + link: { type: "doc", id: `api/${dir}/index` }, + items: require(sidebarModule), + }); + } + } + injectedItems.splice(1, 0, ...customSourceCategories); + + injectedItems.push({ + type: "category", + label: "Server SDK for Python", + items: [ + { + type: "autogenerated", + dirName: "api/server-python", + }, + ], + }); + + return injectedItems; +} + const typedocConfig = { readme: "none", parametersFormat: "table", @@ -275,12 +272,17 @@ const config: Config = { activeBaseRegex: "^/docs(?=/|$)(?!.*(ai-skill|/api(/|$)))", }, { + type: "dropdown", to: "/api/rest", label: "API Reference", position: "left", // Highlight across the whole /api section (rest, reference, and the // typedoc web/mobile/server pages), not just the linked /api/rest. activeBaseRegex: "/api(/|$)", + items: [ + { to: "/api/rest", label: "Server API" }, + { to: "/api/compositions", label: "Composition API" }, + ], }, { to: "/ai-skill", @@ -373,7 +375,19 @@ const config: Config = { route: "/api/rest", showNavLink: false, configuration: { - url: "/docs/api/fishjam-server-openapi.yaml", + sources: [ + { + title: "Server API", + slug: "server", + url: "/docs/api/fishjam-server-openapi.yaml", + default: true, + }, + { + title: "Composition API", + slug: "composition", + url: "/docs/api/composition-openapi.json", + }, + ], hideSearch: true, persistAuth: true, defaultOpenFirstTag: false, @@ -383,6 +397,24 @@ const config: Config = { }, } as ScalarOptions, ], + [ + "@scalar/docusaurus", + { + id: "composition-api", + label: "Composition API", + route: "/api/compositions", + showNavLink: false, + configuration: { + url: "/docs/api/composition-openapi.json", + hideSearch: true, + persistAuth: true, + defaultOpenFirstTag: false, + authentication: { + preferredSecurityScheme: "BearerAuth", + }, + }, + } as ScalarOptions, + ], [ "docusaurus-plugin-typedoc", { diff --git a/package.json b/package.json index 2e063292..b4f32936 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@docusaurus/theme-mermaid": "^3.10.0", "@docusaurus/utils": "^3.10.0", "@fastify/env": "^5.0.2", + "@fishjam-cloud/composition": "link:./packages/js-server-sdk/packages/composition", "@fishjam-cloud/js-server-sdk": "link:./packages/js-server-sdk/packages/js-server-sdk", "@fishjam-cloud/react-client": "link:./packages/web-client-sdk/packages/react-client", "@fishjam-cloud/react-native-client": "link:./packages/web-client-sdk/packages/mobile-client", diff --git a/scripts/update_api.sh b/scripts/update_api.sh index 05c51e68..f29ea75e 100755 --- a/scripts/update_api.sh +++ b/scripts/update_api.sh @@ -37,6 +37,24 @@ copy_openapi() { fi } +COMPOSITION_BRANCH="main" + +# The composition source repo does not tag semver releases yet, so its +# submodule is checked out at a branch instead of the latest tag. +checkout_submodule_branch() { + local submodule_path + submodule_path=api/$1 + + cd "$CWD/$submodule_path" + git fetch origin "$2" + git checkout FETCH_HEAD --detach &>/dev/null +} + +copy_composition_openapi() { + cp openapi.json "$ASSETS_DIRECTORY/composition-openapi.json" + echo "Copied openapi.json of composition to the assets directory." +} + PROTO_FILES="server_notifications agent_notifications notifications/shared" copy_protos() { for file in $PROTO_FILES; do @@ -53,4 +71,7 @@ copy_openapi room-manager checkout_submodule protos copy_protos +checkout_submodule_branch composition $COMPOSITION_BRANCH +copy_composition_openapi + echo $'\nSubmodule update and copy complete.' diff --git a/spelling.txt b/spelling.txt index 27a58018..c1c767ac 100644 --- a/spelling.txt +++ b/spelling.txt @@ -119,6 +119,7 @@ unplugin unorm bgra BGRA +rtmp VoIP CallKit PushKit diff --git a/static/api/composition-openapi.json b/static/api/composition-openapi.json new file mode 100644 index 00000000..cc5aadc5 --- /dev/null +++ b/static/api/composition-openapi.json @@ -0,0 +1,3728 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Composition API", + "description": "Real-time video compositing", + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0" + }, + "version": "0.1.0" + }, + "servers": [ + { + "url": "https://rtc.fishjam.io" + } + ], + "paths": { + "/api/composition": { + "post": { + "tags": ["Compositions"], + "summary": "Create a composition", + "operationId": "create_composition", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCompositionRequest" + }, + "example": { + "autostart": false, + "cleanup_without_inputs": false + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Composition created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompositionCreatedResponse" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Service unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/composition/{composition_id}/output/{output_id}/template": { + "post": { + "tags": ["Outputs"], + "summary": "Register a templated output", + "operationId": "register_template_output", + "parameters": [ + { + "name": "composition_id", + "in": "path", + "description": "Composition ID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "output_id", + "in": "path", + "description": "Output ID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": ["config", "template"], + "properties": { + "config": { + "$ref": "#/components/schemas/RegisterOutput" + }, + "template": { + "type": "string", + "format": "binary" + } + } + }, + "example": { + "config": { + "type": "whip_client", + "endpoint_url": "https://example.com/whip", + "video": { + "resolution": { + "width": 1280, + "height": 720 + }, + "initial": { + "root": { + "type": "view" + } + } + } + }, + "template": "@App.js" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Output registered successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Composition not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Service unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/composition/{composition_id}/room": { + "post": { + "tags": ["Room forwarding"], + "summary": "Link a Fishjam room", + "operationId": "link_room", + "parameters": [ + { + "name": "composition_id", + "in": "path", + "description": "Composition ID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["fishjam_id", "room_id"], + "properties": { + "fishjam_id": { + "type": "string" + }, + "room_id": { + "type": "string" + } + } + }, + "example": { + "fishjam_id": "your-fishjam-id", + "room_id": "room-uuid" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Composition linked to room." + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Composition not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Service unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + }, + "delete": { + "tags": ["Room forwarding"], + "summary": "Unlink a Fishjam room", + "operationId": "unlink_room", + "parameters": [ + { + "name": "composition_id", + "in": "path", + "description": "Composition ID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Composition unlinked from room." + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Composition not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Service unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/composition/{composition_id}/event": { + "post": { + "tags": ["Events"], + "summary": "Send an event to templates", + "operationId": "send_composition_event", + "parameters": [ + { + "name": "composition_id", + "in": "path", + "description": "Composition ID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["event_name"], + "properties": { + "event_name": { + "type": "string", + "description": "Name of the event delivered to the composition's templates.", + "example": "START_LIVE", + "maxLength": 128, + "minLength": 1 + }, + "data": { + "description": "Optional arbitrary JSON payload delivered with the event." + } + } + }, + "example": { + "event_name": "SET_CAPTION", + "data": { + "text": "Welcome to the stream" + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Event accepted (dispatched, or a no-op if no output has an active template).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Composition not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Service unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/composition/{composition_id}/ws": { + "get": { + "tags": ["Events"], + "summary": "Subscribe to engine events", + "description": "Open a WebSocket to the composition.", + "operationId": "ws", + "parameters": [ + { + "name": "composition_id", + "in": "path", + "description": "Composition ID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "101": { + "description": "WebSocket connection established." + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Composition not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "502": { + "description": "Bad gateway.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Service unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "504": { + "description": "Gateway timeout.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + { + "WebSocketSubprotocolAuth": [] + } + ] + } + }, + "/api/composition/{composition_id}": { + "delete": { + "tags": ["Compositions"], + "summary": "Delete a composition", + "operationId": "delete_composition", + "parameters": [ + { + "name": "composition_id", + "in": "path", + "description": "Composition ID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Composition deleted." + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Composition not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/composition/{composition_id}/start": { + "post": { + "tags": ["Compositions"], + "summary": "Start a composition", + "operationId": "start", + "parameters": [ + { + "name": "composition_id", + "in": "path", + "description": "Composition ID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Composition started.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Composition not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/composition/{composition_id}/reset": { + "post": { + "tags": ["Compositions"], + "summary": "Reset a composition", + "operationId": "reset", + "parameters": [ + { + "name": "composition_id", + "in": "path", + "description": "Composition ID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Composition reset.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Composition not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/composition/{composition_id}/input/{input_id}/register": { + "post": { + "tags": ["Inputs"], + "summary": "Register an input", + "operationId": "register_input", + "parameters": [ + { + "name": "composition_id", + "in": "path", + "description": "Composition ID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "input_id", + "in": "path", + "description": "Input ID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterInput" + }, + "example": { + "type": "whip_server" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Input registered successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Composition not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/composition/{composition_id}/output/{output_id}/register": { + "post": { + "tags": ["Outputs"], + "summary": "Register an output", + "operationId": "register_output", + "parameters": [ + { + "name": "composition_id", + "in": "path", + "description": "Composition ID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "output_id", + "in": "path", + "description": "Output ID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterOutput" + }, + "example": { + "type": "whip_client", + "endpoint_url": "https://example.com/whip", + "video": { + "resolution": { + "width": 1280, + "height": 720 + }, + "initial": { + "root": { + "type": "tiles", + "children": [ + { + "type": "input_stream", + "input_id": "camera_1" + }, + { + "type": "input_stream", + "input_id": "camera_2" + } + ] + } + } + }, + "audio": { + "initial": { + "inputs": [ + { + "input_id": "camera_1" + }, + { + "input_id": "camera_2" + } + ] + } + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Output registered successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Composition not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/composition/{composition_id}/image/{image_id}/register": { + "post": { + "tags": ["Renderers"], + "summary": "Register an image", + "operationId": "register_image", + "parameters": [ + { + "name": "composition_id", + "in": "path", + "description": "Composition ID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "image_id", + "in": "path", + "description": "Image ID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageSpec" + }, + "example": { + "asset_type": "png", + "url": "https://example.com/logo.png" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Image registered successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Composition not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/composition/{composition_id}/font/register": { + "post": { + "tags": ["Renderers"], + "summary": "Register a font", + "operationId": "register_font", + "parameters": [ + { + "name": "composition_id", + "in": "path", + "description": "Composition ID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": ["font"], + "properties": { + "font": { + "type": "string", + "format": "binary" + } + } + }, + "example": { + "font": "@Roboto-Regular.ttf" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Font registered successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Composition not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/composition/{composition_id}/input/{input_id}/unregister": { + "post": { + "tags": ["Inputs"], + "summary": "Unregister an input", + "operationId": "unregister_input", + "parameters": [ + { + "name": "composition_id", + "in": "path", + "description": "Composition ID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "input_id", + "in": "path", + "description": "Input ID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnregisterInput" + }, + "example": { + "schedule_time_ms": 5000 + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Input unregistered successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Input not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/composition/{composition_id}/output/{output_id}/unregister": { + "post": { + "tags": ["Outputs"], + "summary": "Unregister an output", + "operationId": "unregister_output", + "parameters": [ + { + "name": "composition_id", + "in": "path", + "description": "Composition ID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "output_id", + "in": "path", + "description": "Output ID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnregisterOutput" + }, + "example": { + "schedule_time_ms": 5000 + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Output unregistered successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Output not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/composition/{composition_id}/image/{image_id}/unregister": { + "post": { + "tags": ["Renderers"], + "summary": "Unregister an image", + "operationId": "unregister_image", + "parameters": [ + { + "name": "composition_id", + "in": "path", + "description": "Composition ID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "image_id", + "in": "path", + "description": "Image ID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnregisterRenderer" + }, + "example": { + "schedule_time_ms": 5000 + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Image unregistered successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Image not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/composition/{composition_id}/output/{output_id}/update": { + "post": { + "tags": ["Outputs"], + "summary": "Update an output's scene", + "operationId": "update_output", + "parameters": [ + { + "name": "composition_id", + "in": "path", + "description": "Composition ID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "output_id", + "in": "path", + "description": "Output ID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOutputRequest" + }, + "example": { + "video": { + "root": { + "type": "rescaler", + "child": { + "type": "input_stream", + "input_id": "camera_1" + } + } + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Output updated successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Composition not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/composition/{composition_id}/output/{output_id}/request_keyframe": { + "post": { + "tags": ["Outputs"], + "summary": "Request a keyframe", + "operationId": "request_keyframe", + "parameters": [ + { + "name": "composition_id", + "in": "path", + "description": "Composition ID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "output_id", + "in": "path", + "description": "Output ID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Keyframe request successful.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Composition not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/composition/{composition_id}/whip/{input_id}": { + "post": { + "tags": ["Media transport"], + "summary": "Publish to an input over WHIP", + "operationId": "whip_offer", + "parameters": [ + { + "name": "composition_id", + "in": "path", + "description": "Composition ID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "input_id", + "in": "path", + "description": "Input ID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "SDP offer.", + "content": { + "application/sdp": { + "schema": { + "type": "string" + }, + "example": "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=mid:0\r\na=sendonly\r\na=rtpmap:96 H264/90000\r\n" + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Session created.", + "headers": { + "location": { + "schema": { + "type": "string" + }, + "description": "Session URL." + } + }, + "content": { + "application/sdp": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Composition or input not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/composition/{composition_id}/whep/{output_id}": { + "post": { + "tags": ["Media transport"], + "summary": "Play an output over WHEP", + "operationId": "whep_offer", + "parameters": [ + { + "name": "composition_id", + "in": "path", + "description": "Composition ID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "output_id", + "in": "path", + "description": "Output ID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "SDP offer.", + "content": { + "application/sdp": { + "schema": { + "type": "string" + }, + "example": "v=0\r\no=- 4611731400430051337 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=mid:0\r\na=recvonly\r\na=rtpmap:96 H264/90000\r\n" + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Session created.", + "headers": { + "location": { + "schema": { + "type": "string" + }, + "description": "Session URL." + } + }, + "content": { + "application/sdp": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Composition or output not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + {}, + { + "BearerAuth": [] + } + ] + } + } + }, + "components": { + "schemas": { + "ApiError": { + "type": "object", + "required": ["message", "http_status_code"], + "properties": { + "message": { + "type": "string" + }, + "http_status_code": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "AspectRatio": { + "type": "string" + }, + "AudioChannels": { + "type": "string", + "enum": ["mono", "stereo"] + }, + "AudioMixingStrategy": { + "type": "string", + "enum": ["sum_clip", "sum_scale"] + }, + "AudioScene": { + "type": "object", + "required": ["inputs"], + "properties": { + "inputs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AudioSceneInput" + } + } + }, + "additionalProperties": false + }, + "AudioSceneInput": { + "type": "object", + "required": ["input_id"], + "properties": { + "input_id": { + "$ref": "#/components/schemas/InputId" + }, + "volume": { + "type": ["number", "null"], + "format": "float", + "description": "(**default=`1.0`**) float in `[0, 2]` range representing input volume" + } + }, + "additionalProperties": false + }, + "BoxShadow": { + "type": "object", + "properties": { + "offset_x": { + "type": ["number", "null"], + "format": "float" + }, + "offset_y": { + "type": ["number", "null"], + "format": "float" + }, + "color": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RGBAColor" + } + ] + }, + "blur_radius": { + "type": ["number", "null"], + "format": "float" + } + }, + "additionalProperties": false + }, + "Component": { + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/InputStream" + } + ], + "title": "ComponentInputStream" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/View" + } + ], + "title": "ComponentView" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/Text" + } + ], + "title": "ComponentText" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/Tiles" + } + ], + "title": "ComponentTiles" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/Rescaler" + } + ], + "title": "ComponentRescaler" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/Image" + } + ], + "title": "ComponentImage" + } + ] + }, + "ComponentId": { + "type": "string" + }, + "CompositionCreatedResponse": { + "type": "object", + "required": ["composition_id", "api_url"], + "properties": { + "composition_id": { + "$ref": "#/components/schemas/CompositionId" + }, + "api_url": { + "type": "string" + } + } + }, + "CompositionEvent": { + "type": "object", + "required": ["event_name"], + "properties": { + "event_name": { + "type": "string", + "description": "Name of the event delivered to the composition's templates.", + "example": "START_LIVE", + "maxLength": 128, + "minLength": 1 + }, + "data": { + "description": "Optional arbitrary JSON payload delivered with the event." + } + } + }, + "CompositionId": { + "type": "string" + }, + "CreateCompositionRequest": { + "type": "object", + "properties": { + "autostart": { + "type": "boolean", + "description": "If true, outputs will immediately start producing audio and video.\nIf false, call `POST /api/composition/{composition_id}/start` to start the composition.", + "default": true + }, + "cleanup_without_inputs": { + "type": "boolean", + "description": "If true (default), the composition will be cleaned up after 5 minutes when all **inputs**\nhave zero bitrate, regardless of output bitrate. This prevents circular liveness when\ncomposition output is sent to a stream.\nIf false, cleanup only triggers when both inputs and outputs have zero bitrate.", + "default": true + } + } + }, + "EasingFunction": { + "oneOf": [ + { + "type": "object", + "title": "EasingFunctionLinear", + "required": ["function_name"], + "properties": { + "function_name": { + "type": "string", + "enum": ["linear"] + } + } + }, + { + "type": "object", + "title": "EasingFunctionBounce", + "required": ["function_name"], + "properties": { + "function_name": { + "type": "string", + "enum": ["bounce"] + } + } + }, + { + "type": "object", + "title": "EasingFunctionCubicBezier", + "required": ["points", "function_name"], + "properties": { + "points": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "function_name": { + "type": "string", + "enum": ["cubic_bezier"] + } + } + } + ], + "description": "Easing functions are used to interpolate between two values over time.\n\nCustom easing functions can be implemented with cubic Bézier.\nThe control points are defined with `points` field by providing four numerical values: `x1`, `y1`, `x2` and `y2`. The `x1` and `x2` values have to be in the range `[0; 1]`. The cubic Bézier result is clamped to the range `[0; 1]`.\nYou can find example control point configurations [here](https://easings.net/)." + }, + "FontUpload": { + "type": "object", + "required": ["font"], + "properties": { + "font": { + "type": "string", + "format": "binary" + } + } + }, + "Framerate": { + "oneOf": [ + { + "type": "string", + "title": "Ratio string" + }, + { + "type": "integer", + "title": "Frames per second", + "format": "int32", + "minimum": 0 + } + ] + }, + "H264EncoderPreset": { + "type": "string", + "enum": [ + "ultrafast", + "superfast", + "veryfast", + "faster", + "fast", + "medium", + "slow", + "slower", + "veryslow", + "placebo" + ] + }, + "HlsInput": { + "type": "object", + "description": "Parameters for an input stream from HLS source.", + "required": ["url", "type"], + "properties": { + "url": { + "type": "string", + "description": "URL to HLS playlist" + }, + "type": { + "type": "string", + "enum": ["hls"] + } + }, + "additionalProperties": false + }, + "HorizontalAlign": { + "type": "string", + "enum": ["left", "right", "justified", "center"] + }, + "Image": { + "type": "object", + "required": ["image_id", "type"], + "properties": { + "id": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ComponentId", + "description": "Id of a component." + } + ] + }, + "image_id": { + "$ref": "#/components/schemas/RendererId", + "description": "Id of an image. It identifies an image registered using a `register image` request." + }, + "width": { + "type": ["number", "null"], + "format": "float", + "description": "Width of the image in pixels.\nIf `height` is not explicitly provided, the image will automatically adjust its height to maintain its original aspect ratio relative to the width." + }, + "height": { + "type": ["number", "null"], + "format": "float", + "description": "Height of the image in pixels.\nIf `width` is not explicitly provided, the image will automatically adjust its width to maintain its original aspect ratio relative to the height." + }, + "type": { + "type": "string", + "enum": ["image"] + } + }, + "additionalProperties": false + }, + "ImageSpec": { + "oneOf": [ + { + "type": "object", + "title": "ImageSpecPng", + "required": ["asset_type"], + "properties": { + "url": { + "type": ["string", "null"] + }, + "asset_type": { + "type": "string", + "enum": ["png"] + } + } + }, + { + "type": "object", + "title": "ImageSpecJpeg", + "required": ["asset_type"], + "properties": { + "url": { + "type": ["string", "null"] + }, + "asset_type": { + "type": "string", + "enum": ["jpeg"] + } + } + }, + { + "type": "object", + "title": "ImageSpecSvg", + "required": ["asset_type"], + "properties": { + "url": { + "type": ["string", "null"] + }, + "resolution": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Resolution" + } + ] + }, + "asset_type": { + "type": "string", + "enum": ["svg"] + } + } + }, + { + "type": "object", + "title": "ImageSpecGif", + "required": ["asset_type"], + "properties": { + "url": { + "type": ["string", "null"] + }, + "asset_type": { + "type": "string", + "enum": ["gif"] + } + } + }, + { + "type": "object", + "title": "ImageSpecAuto", + "required": ["asset_type"], + "properties": { + "url": { + "type": ["string", "null"] + }, + "asset_type": { + "type": "string", + "enum": ["auto"] + } + } + } + ] + }, + "InputId": { + "type": "string" + }, + "InputStream": { + "type": "object", + "required": ["input_id", "type"], + "properties": { + "id": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ComponentId", + "description": "Id of a component." + } + ] + }, + "input_id": { + "$ref": "#/components/schemas/InputId", + "description": "Id of an input. It identifies a stream registered using a `RegisterInputStream` request." + }, + "type": { + "type": "string", + "enum": ["input_stream"] + } + }, + "additionalProperties": false + }, + "Interpolation": { + "type": "string", + "enum": ["linear", "spring"] + }, + "Mp4Input": { + "type": "object", + "description": "Input stream from an MP4 file.", + "required": ["type"], + "properties": { + "url": { + "type": ["string", "null"], + "description": "URL of the MP4 file." + }, + "loop": { + "type": ["boolean", "null"], + "description": "(**default=`false`**) If input should be played in the loop. Added in v0.4.0" + }, + "type": { + "type": "string", + "enum": ["mp4"] + } + }, + "additionalProperties": false + }, + "OpusEncoderPreset": { + "type": "string", + "enum": ["quality", "voip", "lowest_latency"] + }, + "OutputEndCondition": { + "type": "object", + "description": "This type defines when end of an input stream should trigger end of the output stream. Only one of those fields can be set at the time.\nUnless specified otherwise the input stream is considered finished/ended when:\n- TCP connection was dropped/closed.\n- RTCP Goodbye packet (`BYE`) was received.\n- Mp4 track has ended.\n- Input was unregistered already (or never registered).", + "properties": { + "any_of": { + "type": ["array", "null"], + "items": { + "$ref": "#/components/schemas/InputId" + }, + "description": "Terminate output stream if any of the input streams from the list are finished." + }, + "all_of": { + "type": ["array", "null"], + "items": { + "$ref": "#/components/schemas/InputId" + }, + "description": "Terminate output stream if all the input streams from the list are finished." + }, + "any_input": { + "type": ["boolean", "null"], + "description": "Terminate output stream if any of the input streams ends. This includes streams added after the output was registered. In particular, output stream will **not be** terminated if no inputs were ever connected." + }, + "all_inputs": { + "type": ["boolean", "null"], + "description": "Terminate output stream if all the input streams finish. In particular, output stream will **be** terminated if no inputs were ever connected." + } + }, + "additionalProperties": false + }, + "OutputId": { + "type": "string" + }, + "OutputRtmpClientAudioOptions": { + "type": "object", + "required": ["initial"], + "properties": { + "mixing_strategy": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/AudioMixingStrategy", + "description": "(**default=\"sum_clip\"**) Specifies how audio should be mixed." + } + ] + }, + "send_eos_when": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/OutputEndCondition", + "description": "Condition for termination of the output stream based on the input streams states. If output includes both audio and video streams, then EOS needs to be sent for every type." + } + ] + }, + "channels": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/AudioChannels", + "description": "Channels configuration." + } + ] + }, + "initial": { + "$ref": "#/components/schemas/AudioScene", + "description": "Initial audio mixer configuration for output." + } + }, + "additionalProperties": false + }, + "OutputRtmpClientVideoOptions": { + "type": "object", + "required": ["resolution", "initial"], + "properties": { + "resolution": { + "$ref": "#/components/schemas/Resolution", + "description": "Output resolution in pixels." + }, + "send_eos_when": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/OutputEndCondition", + "description": "Condition for termination of the output stream based on the input streams states. If output includes both audio and video streams, then EOS needs to be sent for every type." + } + ] + }, + "initial": { + "$ref": "#/components/schemas/VideoScene", + "description": "Root of a component tree/scene that should be rendered for the output. Use `update_output` request to update this value after registration. Learn more." + } + }, + "additionalProperties": false + }, + "OutputWhipAudioOptions": { + "type": "object", + "required": ["initial"], + "properties": { + "mixing_strategy": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/AudioMixingStrategy", + "description": "(**default=\"sum_clip\"**) Specifies how audio should be mixed." + } + ] + }, + "send_eos_when": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/OutputEndCondition", + "description": "Condition for termination of output stream based on the input streams states." + } + ] + }, + "channels": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/AudioChannels", + "description": "Specifies channels configuration." + } + ] + }, + "encoder_preferences": { + "type": ["array", "null"], + "items": { + "$ref": "#/components/schemas/WhipAudioEncoderOptions" + }, + "description": "Codec preferences list." + }, + "initial": { + "$ref": "#/components/schemas/AudioScene", + "description": "Initial audio mixer configuration for output." + } + }, + "additionalProperties": false + }, + "OutputWhipVideoOptions": { + "type": "object", + "required": ["resolution", "initial"], + "properties": { + "resolution": { + "$ref": "#/components/schemas/Resolution", + "description": "Output resolution in pixels." + }, + "send_eos_when": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/OutputEndCondition", + "description": "Defines when output stream should end if some of the input streams are finished. If output includes both audio and video streams, then EOS needs to be sent on both." + } + ] + }, + "initial": { + "$ref": "#/components/schemas/VideoScene", + "description": "Root of a component tree/scene that should be rendered for the output." + } + }, + "additionalProperties": false + }, + "Overflow": { + "type": "string", + "enum": ["visible", "hidden", "fit"] + }, + "PixelFormat": { + "type": "string", + "enum": ["yuv420p", "yuv422p", "yuv444p"] + }, + "PortOrPortRange": { + "oneOf": [ + { + "type": "string", + "title": "Port range string" + }, + { + "type": "integer", + "title": "Single port", + "format": "int32", + "minimum": 0 + } + ] + }, + "RGBAColor": { + "type": "string" + }, + "RegisterInput": { + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/RtmpInput" + } + ], + "title": "RegisterInputRtmpServer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/Mp4Input" + } + ], + "title": "RegisterInputMp4" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/WhipInput" + } + ], + "title": "RegisterInputWhipServer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/WhepInput" + } + ], + "title": "RegisterInputWhepClient" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/HlsInput" + } + ], + "title": "RegisterInputHls" + } + ] + }, + "RegisterOutput": { + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/RtmpOutput" + } + ], + "title": "RegisterOutputRtmpClient" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/WhipOutput" + } + ], + "title": "RegisterOutputWhipClient" + } + ] + }, + "RegisterTemplateOutput": { + "type": "object", + "required": ["config", "template"], + "properties": { + "config": { + "$ref": "#/components/schemas/RegisterOutput" + }, + "template": { + "type": "string", + "format": "binary" + } + } + }, + "RendererId": { + "type": "string" + }, + "RescaleMode": { + "type": "string", + "enum": ["fit", "fill"] + }, + "Rescaler": { + "type": "object", + "required": ["child", "type"], + "properties": { + "id": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ComponentId", + "description": "Id of a component." + } + ] + }, + "child": { + "$ref": "#/components/schemas/Component", + "description": "List of component's children." + }, + "mode": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RescaleMode", + "description": "(**default=`\"fit\"`**) Resize mode:" + } + ] + }, + "horizontal_align": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/HorizontalAlign", + "description": "(**default=`\"center\"`**) Horizontal alignment." + } + ] + }, + "vertical_align": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/VerticalAlign", + "description": "(**default=`\"center\"`**) Vertical alignment." + } + ] + }, + "width": { + "type": ["number", "null"], + "format": "float", + "description": "Width of a component in pixels (without a border). Exact behavior might be different\nbased on the parent component:\n- If the parent component is a layout, check sections \"Absolute positioning\" and \"Static\n positioning\" of that component.\n- If the parent component is not a layout, then this field is required." + }, + "height": { + "type": ["number", "null"], + "format": "float", + "description": "Height of a component in pixels (without a border). Exact behavior might be different\nbased on the parent component:\n- If the parent component is a layout, check sections \"Absolute positioning\" and \"Static\n positioning\" of that component.\n- If the parent component is not a layout, then this field is required." + }, + "top": { + "type": ["number", "null"], + "format": "float", + "description": "Distance in pixels between this component's top edge and its parent's top edge (including a border).\nIf this field is defined, then the component will ignore a layout defined by its parent." + }, + "left": { + "type": ["number", "null"], + "format": "float", + "description": "Distance in pixels between this component's left edge and its parent's left edge (including a border).\nIf this field is defined, this element will be absolutely positioned, instead of being\nlaid out by its parent." + }, + "bottom": { + "type": ["number", "null"], + "format": "float", + "description": "Distance in pixels between the bottom edge of this component and the bottom edge of its\nparent (including a border). If this field is defined, this element will be absolutely\npositioned, instead of being laid out by its parent." + }, + "right": { + "type": ["number", "null"], + "format": "float", + "description": "Distance in pixels between this component's right edge and its parent's right edge.\nIf this field is defined, this element will be absolutely positioned, instead of being\nlaid out by its parent." + }, + "rotation": { + "type": ["number", "null"], + "format": "float", + "description": "Rotation of a component in degrees. If this field is defined, this element will be\nabsolutely positioned, instead of being laid out by its parent." + }, + "transition": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Transition", + "description": "Defines how this component will behave during a scene update. This will only have an\neffect if the previous scene already contained a `Rescaler` component with the same id." + } + ] + }, + "border_radius": { + "type": ["number", "null"], + "format": "float", + "description": "(**default=`0.0`**) Radius of a rounded corner." + }, + "border_width": { + "type": ["number", "null"], + "format": "float", + "description": "(**default=`0.0`**) Border width." + }, + "border_color": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RGBAColor", + "description": "(**default=`\"#00000000\"`**) Border color in a `\"#RRGGBBAA\"` format." + } + ] + }, + "box_shadow": { + "type": ["array", "null"], + "items": { + "$ref": "#/components/schemas/BoxShadow" + }, + "description": "List of box shadows." + }, + "type": { + "type": "string", + "enum": ["rescaler"] + } + }, + "additionalProperties": false + }, + "Resolution": { + "type": "object", + "required": ["width", "height"], + "properties": { + "width": { + "type": "integer", + "description": "Width in pixels.", + "minimum": 0 + }, + "height": { + "type": "integer", + "description": "Height in pixels.", + "minimum": 0 + } + } + }, + "Response": { + "oneOf": [ + { + "type": "object", + "title": "Bearer token", + "required": ["bearer_token"], + "properties": { + "bearer_token": { + "type": "string" + } + } + }, + { + "type": "object", + "title": "Media durations", + "properties": { + "video_duration_ms": { + "type": ["integer", "null"], + "format": "int64", + "minimum": 0 + }, + "audio_duration_ms": { + "type": ["integer", "null"], + "format": "int64", + "minimum": 0 + } + } + }, + { + "type": "object", + "title": "Port", + "properties": { + "port": { + "type": ["integer", "null"], + "format": "int32", + "minimum": 0 + } + } + }, + { + "type": "object", + "title": "Empty" + } + ] + }, + "RoomLink": { + "type": "object", + "required": ["fishjam_id", "room_id"], + "properties": { + "fishjam_id": { + "type": "string" + }, + "room_id": { + "type": "string" + } + } + }, + "RtmpInput": { + "type": "object", + "required": ["app", "stream_key", "type"], + "properties": { + "app": { + "type": "string", + "description": "The RTMP application name.\nThis is the first path segment of the RTMP stream URL that Smelter listens on for incoming streams.\nFormat: `rtmp://://://`." + } + } + }, + "tags": [ + { + "name": "Compositions", + "description": "A composition is a single running compositing session. Create one, start it, and delete it when you are done." + }, + { + "name": "Inputs", + "description": "Live media sources being composed: WHIP, WHEP, RTMP, HLS, or MP4." + }, + { + "name": "Outputs", + "description": "Where the composed result is sent, over WHIP or RTMP. Each output carries a scene or renders a template." + }, + { + "name": "Renderers", + "description": "Shared assets, such as images and fonts, that scenes can reference." + }, + { + "name": "Events", + "description": "Custom events delivered to templates, and the WebSocket stream of engine events." + }, + { + "name": "Room forwarding", + "description": "Links a Fishjam room to the composition. Called by Fishjam automatically when a room's track forwarding is created." + }, + { + "name": "Media transport", + "description": "WHIP publishing into inputs and WHEP playback of outputs." + } + ] +} diff --git a/yarn.lock b/yarn.lock index efa7881d..b78869c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3719,6 +3719,12 @@ __metadata: languageName: node linkType: hard +"@fishjam-cloud/composition@link:./packages/js-server-sdk/packages/composition::locator=fishjam-docs%40workspace%3A.": + version: 0.0.0-use.local + resolution: "@fishjam-cloud/composition@link:./packages/js-server-sdk/packages/composition::locator=fishjam-docs%40workspace%3A." + languageName: node + linkType: soft + "@fishjam-cloud/js-server-sdk@link:./packages/js-server-sdk/packages/js-server-sdk::locator=fishjam-docs%40workspace%3A.": version: 0.0.0-use.local resolution: "@fishjam-cloud/js-server-sdk@link:./packages/js-server-sdk/packages/js-server-sdk::locator=fishjam-docs%40workspace%3A." @@ -11931,6 +11937,7 @@ __metadata: "@docusaurus/types": "npm:^3.10.0" "@docusaurus/utils": "npm:^3.10.0" "@fastify/env": "npm:^5.0.2" + "@fishjam-cloud/composition": "link:./packages/js-server-sdk/packages/composition" "@fishjam-cloud/js-server-sdk": "link:./packages/js-server-sdk/packages/js-server-sdk" "@fishjam-cloud/react-client": "link:./packages/web-client-sdk/packages/react-client" "@fishjam-cloud/react-native-client": "link:./packages/web-client-sdk/packages/mobile-client"