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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@ Fishjam JavaScript/TypeScript server SDK — a Yarn (Berry, v4) workspace.
Node (via nvm) and Corepack/Yarn are pre-installed; the startup script runs `yarn install`. Non-obvious notes:

- Use Corepack-managed Yarn; if `yarn` is not found, run `corepack enable` and ensure the nvm Node bin is on `PATH`.
- Validated commands: `yarn build`, `yarn typecheck`, `yarn lint:check` (all pass). There is no unit-test script; the SDK is exercised through consuming repos (e.g. `room-manager`).
- Validated commands: `yarn build`, `yarn typecheck`, `yarn lint:check`, `yarn workspace @fishjam-cloud/js-server-sdk test` (all pass).
2 changes: 2 additions & 0 deletions examples/composition/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
FISHJAM_ID=""
FISHJAM_TOKEN=""
3 changes: 3 additions & 0 deletions examples/composition/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
.env
dist
43 changes: 43 additions & 0 deletions examples/composition/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Compositions with Fishjam

This example composes everyone in a Fishjam room into a single video and streams it to livestream viewers.
Peers join a room, their tracks are forwarded into a composition, and a template you can edit decides how
they are laid out.

## Development

To start the server you must first copy `.env.example` to `.env`.

Then you need to set the following variables:

- `FISHJAM_ID`: your Fishjam ID, which you can get at <https://fishjam.io>
- `FISHJAM_TOKEN`: your Fishjam management token, which you can get at <https://fishjam.io>
- `COMPOSITION_URL`: address of the Composition API, only needed to point the example at something other
than production, such as a local cluster. Leave it unset otherwise.

Once you've set up your environment variables, all you need to do is run the following command:

```bash
yarn dev
```

This bundles `template/App.tsx` into the template the composition renders, creates the room, the livestream
and the composition, uploads the bundle with the composition's output, and then serves peer and viewer tokens. Requires Node 23 or newer, which runs the TypeScript sources directly.

When the server is running, you can obtain peer tokens by going to <http://127.0.0.1:3000/peers>, and a
livestream viewer token from <http://127.0.0.1:3000/viewer>.

Connect peers with the [fishjam minimal-react example](https://github.com/fishjam-cloud/web-client-sdk/tree/main/examples/react-client)
and watch the composed result with a livestream viewer. Press Ctrl+C to delete the composition and its rooms.

Press Enter in the terminal to cycle the scene, which sends an event the template reacts to.

## Editing the template

`template/App.tsx` is a regular React component rendered by Smelter, using every hook the composition
package offers: `usePeers` lays out whoever is connected, `usePeer` resolves the spotlighted one,
`useSpeakingState` outlines whoever is talking, `useRoom` tells the template the room is linked, and
`eventBus` receives the scene events. Colours come from the Fishjam palette and the text is set in Inter,
uploaded at startup with `registerFont`.

Change it, restart `yarn dev`, and the new bundle is uploaded with the output.
31 changes: 31 additions & 0 deletions examples/composition/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "composition-demo",
"version": "0.29.0",
"private": true,
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"format": "prettier --write .",
"typecheck": "tsc",
"build:template": "composition-cli build template/App.tsx --out dist/template.js",
"dev": "yarn build:template && node --env-file-if-exists=.env src/index.ts"
},
"dependencies": {
"@fishjam-cloud/js-server-sdk": "workspace:*",
"@hono/node-server": "^1.13.0",
"hono": "^4.6.0"
},
"devDependencies": {
"@fishjam-cloud/composition": "workspace:*",
"@fishjam-cloud/composition-cli": "workspace:*",
"@swmansion/smelter": "0.3.0",
"@types/node": "^22.13.16",
"@types/react": "^18.3.0",
"prettier": "^3.6.2",
"react": "^18.3.1",
"typescript": "^5.9.3"
},
"engines": {
"node": ">=23"
}
}
19 changes: 19 additions & 0 deletions examples/composition/src/const.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { OutputId, RendererId } from '@fishjam-cloud/js-server-sdk';

export const PORT = 3000;

export const HOSTNAME = '127.0.0.1';

export const PREVIEW_OUTPUT_ID = 'preview' as OutputId;

export const FONT_ID = 'inter' as RendererId;

export const FONT_URL = 'https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf';

export const LOGO_URL = 'https://fishjam.swmansion.com/favicon.svg';

export const LOGO_RESOLUTION = { width: 200, height: 200 };

export const OUTPUT_RESOLUTION = { width: 1280, height: 720 };

export const TEMPLATE_BUNDLE = new URL('../dist/template.js', import.meta.url).pathname;
8 changes: 8 additions & 0 deletions examples/composition/src/controllers/peers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Hono } from 'hono';
import type { FishjamService } from '../service/fishjam.ts';

export const peerController = (fishjam: FishjamService) =>
new Hono().get('/peers', async (c) => {
const { peerToken } = await fishjam.createPeer();
return c.json({ token: peerToken, fishjamId: process.env.FISHJAM_ID });
});
8 changes: 8 additions & 0 deletions examples/composition/src/controllers/viewers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Hono } from 'hono';
import type { FishjamService } from '../service/fishjam.ts';

export const viewerController = (fishjam: FishjamService) =>
new Hono().get('/viewer', async (c) => {
const { token } = await fishjam.createViewerToken();
return c.json({ token, fishjamId: process.env.FISHJAM_ID });
});
11 changes: 11 additions & 0 deletions examples/composition/src/environment.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
declare global {
namespace NodeJS {
interface ProcessEnv {
FISHJAM_ID: string;
FISHJAM_TOKEN: string;
COMPOSITION_URL?: string;
}
}
}

export {};
73 changes: 73 additions & 0 deletions examples/composition/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { createInterface } from 'node:readline';
import { HOSTNAME, PORT } from './const.ts';
import { peerController } from './controllers/peers.ts';
import { viewerController } from './controllers/viewers.ts';
import { CompositionService } from './service/composition.ts';
import { FishjamService } from './service/fishjam.ts';

if (!process.env.FISHJAM_ID || !process.env.FISHJAM_TOKEN) {
throw Error('Environment variables FISHJAM_ID and FISHJAM_TOKEN are required.');
}

const fishjam = await FishjamService.create({
fishjamId: process.env.FISHJAM_ID,
managementToken: process.env.FISHJAM_TOKEN,
});

let composition: CompositionService | undefined;

const cleanup = async () => {
const results = await Promise.allSettled([composition?.cleanup(), fishjam.cleanup()]);
const failure = results.find((result) => result.status === 'rejected');

if (failure) throw failure.reason;
};

try {
composition = await CompositionService.create({
managementToken: process.env.FISHJAM_TOKEN,
compositionUrl: process.env.COMPOSITION_URL,
});

const streamer = await fishjam.createStreamerToken();
await composition.useFishjamAssets();
await composition.streamTo(fishjam.livestreamWhipUrl(), streamer.token);
await fishjam.composeRoomInto(composition.url);
} catch (error) {
console.error('failed to start composing:', error);
await cleanup();
process.exit(1);
}

console.log(`composing room ${fishjam.roomId} into livestream ${fishjam.livestreamId}`);
console.log('press Enter to change the scene');

const scenes = createInterface({ input: process.stdin });
let sceneIndex = 0;

scenes.on('line', () => {
composition!
.showScene(++sceneIndex)
.then(({ layout, background }) => console.log(`scene: ${layout} on ${background}`))
.catch((error) => console.error('failed to change the scene:', error));
});

const teardown = () => {
scenes.close();
cleanup()
.then(() => console.log('\ndeleted the composition and its rooms'))
.catch((error) => console.error('cleanup failed:', error))
.finally(() => process.exit(0));
};

process.once('SIGINT', teardown);
process.once('SIGTERM', teardown);
scenes.once('SIGINT', teardown);

const app = new Hono().route('/', peerController(fishjam)).route('/', viewerController(fishjam));

serve({ fetch: app.fetch, port: PORT, hostname: HOSTNAME }, ({ port }) => {
console.log(`peer tokens on http://${HOSTNAME}:${port}/peers, viewer token on http://${HOSTNAME}:${port}/viewer`);
});
68 changes: 68 additions & 0 deletions examples/composition/src/service/composition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { CompositionClient } from '@fishjam-cloud/js-server-sdk';
import type { CompositionConfig, CompositionId, RendererId } from '@fishjam-cloud/js-server-sdk';
import {
FONT_URL,
LOGO_RESOLUTION,
LOGO_URL,
OUTPUT_RESOLUTION,
PREVIEW_OUTPUT_ID,
TEMPLATE_BUNDLE,
} from '../const.ts';
import { LOGO_ID, SCENES, SCENE_EVENT, type Scene } from '../../template/scene.ts';

export class CompositionService {
private readonly compositions: CompositionClient;
readonly compositionId: CompositionId;

private constructor(compositions: CompositionClient, compositionId: CompositionId) {
this.compositions = compositions;
this.compositionId = compositionId;
}

static async create(config: CompositionConfig): Promise<CompositionService> {
const compositions = new CompositionClient(config);
const { compositionId } = await compositions.createComposition();

return new CompositionService(compositions, compositionId);
}

get url() {
return this.compositions.compositionUrl(this.compositionId);
}

async useFishjamAssets() {
const font = await fetch(FONT_URL);
await this.compositions.registerFont(this.compositionId, await font.blob());
await this.compositions.registerImage(this.compositionId, LOGO_ID as RendererId, {
assetType: 'svg',
url: LOGO_URL,
resolution: LOGO_RESOLUTION,
});
}

async showScene(index: number): Promise<Scene> {
const scene = SCENES[index % SCENES.length];
await this.compositions.sendEvent(this.compositionId, { eventName: SCENE_EVENT, data: scene });

return scene;
}

async streamTo(endpointUrl: string, bearerToken: string) {
await this.compositions.registerTemplateOutput(
this.compositionId,
PREVIEW_OUTPUT_ID,
{
type: 'whip_client',
endpointUrl,
bearerToken,
video: { resolution: OUTPUT_RESOLUTION, initial: { root: { type: 'view' } } },
audio: { initial: { inputs: [] } },
},
TEMPLATE_BUNDLE
);
}

async cleanup() {
await this.compositions.deleteComposition(this.compositionId);
}
}
58 changes: 58 additions & 0 deletions examples/composition/src/service/fishjam.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { FishjamClient } from '@fishjam-cloud/js-server-sdk';
import type { FishjamConfig, RoomId } from '@fishjam-cloud/js-server-sdk';

export class FishjamService {
private readonly fishjam: FishjamClient;
readonly roomId: RoomId;
readonly livestreamId: RoomId;

private constructor(fishjam: FishjamClient, roomId: RoomId, livestreamId: RoomId) {
this.fishjam = fishjam;
this.roomId = roomId;
this.livestreamId = livestreamId;
}

static async create(config: FishjamConfig): Promise<FishjamService> {
const fishjam = await FishjamClient.create(config);
const room = await fishjam.createRoom();

try {
const livestream = await fishjam.createRoom({ roomType: 'livestream' });

return new FishjamService(fishjam, room.id, livestream.id);
} catch (error) {
await fishjam.deleteRoom(room.id);
throw error;
}
}

async createPeer() {
return this.fishjam.createPeer(this.roomId);
}

async createViewerToken() {
return this.fishjam.createLivestreamViewerToken(this.livestreamId);
}

async createStreamerToken() {
return this.fishjam.createLivestreamStreamerToken(this.livestreamId);
}

livestreamWhipUrl() {
return this.fishjam.livestreamWhipUrl();
}

async composeRoomInto(compositionUrl: string) {
await this.fishjam.forwardRoomTracks(this.roomId, compositionUrl);
}

async cleanup() {
const results = await Promise.allSettled([
this.fishjam.deleteRoom(this.roomId),
this.fishjam.deleteRoom(this.livestreamId),
]);
const failure = results.find((result) => result.status === 'rejected');

if (failure) throw failure.reason;
}
}
Loading
Loading