feat: redesign match play and add broadcast tooling
This commit is contained in:
204
streamer/README.md
Normal file
204
streamer/README.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# Streaming Vash matches
|
||||
|
||||
For a first test on Windows with Ubuntu WSL, follow [the WSL setup guide](WSL.md).
|
||||
|
||||
Vash can put its matches on stream by itself. There are three ways to use it, from no PC at all to a full tournament broadcast.
|
||||
|
||||
| Setup | What viewers see | Needs |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
|
||||
| **Vash TV** (headless container) | Vash's own data: scoreboard, mappool with picks and bans, live scores, predictions, chat, ticker | A small cloud VM or any Docker host |
|
||||
| **Overlay in OBS** | Your OBS scenes with the Vash overlay on top | OBS |
|
||||
| **Full tourney broadcast** | Real osu!tourney gameplay under the overlay, live scores, and the same multistream and recording | Dedicated Docker host with Wine, osu!, and tosu, or a Windows PC with OBS |
|
||||
|
||||
```
|
||||
Vash match runtime ─▶ director ─▶ channel websocket ─▶ tourney agent
|
||||
▲ │ room selection
|
||||
│ live scores ▼
|
||||
└────────── tosu ◀────────────── osu!tourney under Wine
|
||||
│ gameplay screen
|
||||
Vash overlay page ──green key──▶ ffmpeg compositor ◀┘
|
||||
│ one encoded stream
|
||||
├──▶ Twitch / YouTube / other RTMP
|
||||
└──▶ Vash live video and recording
|
||||
```
|
||||
|
||||
## 1. Create a channel
|
||||
|
||||
Referees and admins open **Broadcast** in the sidebar and create a channel. A channel shows one of these:
|
||||
|
||||
- **Whatever is most worth watching.** The director picks among all live matches: tournament matches first, then close series near match point, tiebreakers, and busy matches. It never cuts away during a map and stays on a result for 30 seconds.
|
||||
- **A tournament's matches**, by the same rules.
|
||||
- **One match.**
|
||||
|
||||
Pick a starting layout; each shows a live preview:
|
||||
|
||||
| Layout | Use it when |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| **Over osu! gameplay** | Capture osu!tourney in the autonomous container or OBS. The gameplay scene leaves a see-through window for it. |
|
||||
| **Vash only** | The automatic stream (Vash TV), or any stream without an osu! capture. Everything comes from Vash's data. |
|
||||
| **Scoreboard only** | You have your own OBS scenes and only want the score on top. |
|
||||
| **Phone (portrait)** | TikTok, Instagram or YouTube Shorts. Makes a 9:16 channel. |
|
||||
|
||||
The agent token shown after creating the channel is for the stream container and `vash-streamer`. It is shown once; **Agent token** makes a new one.
|
||||
|
||||
### Layout editor
|
||||
|
||||
**Edit layout** opens the editor. Each scene can be arranged on its own: _Starting soon_, _Lobby_, _Pick & ban_, _Gameplay_ and _Results_. The overlay switches scenes as the match moves on.
|
||||
|
||||
- **Drag** a component to move it and drag its corner to resize. Movement snaps to a 10 px grid; hold Alt to move freely.
|
||||
- **Arrow keys** nudge the selected component (Shift for 10 px). Delete removes it and Ctrl+D duplicates it.
|
||||
- **Components:** scoreboard, live score, now playing, mappool, turn and timer, map history, lobby chat, crowd predictions, players, winner, gameplay window, live ticker of other matches, schedule, text, image, and clock.
|
||||
- **Backgrounds:** each scene can be see-through, the theme background, or an image.
|
||||
- **Demo match:** when nothing is live, the editor shows a made-up match so every component has something to draw.
|
||||
|
||||
Saving updates every open overlay immediately, including the one in OBS.
|
||||
|
||||
### The overlay link
|
||||
|
||||
`https://<site>/broadcast/<channel>/overlay` is a 1920×1080 page with a transparent background. It needs no login and shows only public match data.
|
||||
|
||||
- `?bg=solid` paints the see-through scenes too, for streams with nothing underneath.
|
||||
- `?scene=gameplay` pins one scene, if you prefer one browser source per OBS scene.
|
||||
|
||||
## 2. Overlay in OBS
|
||||
|
||||
Add a **Browser** source with the overlay link, 1920×1080, on top of your scene. That is all. To put gameplay under it, capture anything below the browser source; the gameplay scene of the _Tourney client_ preset leaves that area see-through.
|
||||
|
||||
## 3. Full tourney broadcast (Windows)
|
||||
|
||||
Live scores for real lobbies only come from the osu! tourney client. Bancho's IRC only reports results once a map ends. So the streaming PC runs:
|
||||
|
||||
1. **osu! tourney client.** Put a `tournament.cfg` next to `osu!.exe` (see the [osu! wiki](https://osu.ppy.sh/wiki/en/osu!tourney)) and start it with a spare account.
|
||||
2. **tosu** ([tosuapp/tosu](https://github.com/tosuapp/tosu)). It reads the tourney client's memory. Its defaults work.
|
||||
3. **OBS 28 or newer** with _Tools → WebSocket Server Settings_ enabled.
|
||||
4. **vash-streamer.exe**, built with `bun run streamer:build` (output in `dist/`). It is one file with no install.
|
||||
|
||||
```powershell
|
||||
# Once: create a "Vash" scene with the tourney client capture and the overlay on top.
|
||||
.\dist\vash-streamer.exe --server https://esports.vash.software --channel <id> --token <token> --obs-password <pw> --setup-obs
|
||||
|
||||
# Every stream:
|
||||
.\dist\vash-streamer.exe --server https://esports.vash.software --channel <id> --token <token> --obs-password <pw> --obs-scenes '{"idle":"Starting Soon","gameplay":"Vash"}'
|
||||
```
|
||||
|
||||
While it runs:
|
||||
|
||||
- **Live scores.** Live scores from tosu reach Vash four times a second, matched to players by osu! id. They drive the overlay's live score bar and the match page on the site.
|
||||
- **OBS scenes.** With `--obs-scenes`, OBS switches scenes as the match does. The scene names are `idle`, `lobby`, `pickban`, `gameplay` and `results`.
|
||||
- On Windows, select the named room in osu!tourney's bottom control panel. The container setup below performs this selection automatically under Wine/X11.
|
||||
|
||||
The lazer tournament client can run alongside for its own screens, since it reads the stable client's IPC. Vash does not drive it; the Vash overlay covers the same ground with live data.
|
||||
|
||||
## 4. Go live on Twitch, YouTube and more
|
||||
|
||||
Open a channel's **Stream** page:
|
||||
|
||||
1. **Add destinations.** Twitch, YouTube, Facebook, X, Instagram, TikTok, or any RTMP server; several of one kind is fine (two YouTube channels, say). Each platform shows where to find its stream key. Keys are stored encrypted and only sent to this channel's stream container.
|
||||
2. **Pick the shape and quality.** Landscape (16:9) for Twitch, YouTube, Facebook and X; portrait (9:16) for TikTok and Instagram. To stream both at once, make a second, portrait channel: both follow the same matches.
|
||||
3. **Start the stream container on your PC or Docker host, then press Go live.** Go live changes the channel's state; it does not launch Docker. A connected container goes on air, reports each destination's state on the page, and reconnects any destination that drops. **End stream** tells it to stop streaming.
|
||||
|
||||
**Recordings.** Every broadcast is also cut into 2-second HLS segments and uploaded to storage (R2). That is the live stream on the site (the **Live** page and the channel's **Watch page**), and afterwards the recording, with chapters that jump to each match. Match pages link to the broadcast that showed them. Delete old recordings from the Stream page.
|
||||
|
||||
For playback in Chrome and Firefox, the bucket needs a CORS rule (Cloudflare dashboard → R2 → bucket → Settings → CORS policy):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"AllowedOrigins": [
|
||||
"https://esports.vash.software",
|
||||
"https://esports-dev.vash.software",
|
||||
"https://esports-staging.vash.software"
|
||||
],
|
||||
"AllowedMethods": ["GET", "HEAD"],
|
||||
"AllowedHeaders": ["*"],
|
||||
"MaxAgeSeconds": 3600
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**VODs on the platforms.** YouTube keeps every stream as a video by itself. On Twitch, turn on _Store past broadcasts_ (Settings → Stream); Twitch keeps them 7 days, or 60 for Partners, Turbo and Prime, so the copy on Vash's storage is the one that lasts. Facebook and X keep the post; Instagram asks to share it when the stream ends.
|
||||
|
||||
## 5. The stream container (Vash TV)
|
||||
|
||||
The `streamer/tv` image renders a channel in Chromium on a virtual screen, encodes it once, and copies the stream to every destination and to the recording. It follows the channel's **Go live** switch, so the container can idle between matches. To use your own PC for now, build and run it there:
|
||||
|
||||
```sh
|
||||
docker build -t vash-tv streamer/tv
|
||||
docker run -d --restart unless-stopped --shm-size=1g --name vash-tv \
|
||||
-e VASH_SERVER=https://esports.vash.software \
|
||||
-e VASH_CHANNEL=<channel id> \
|
||||
-e VASH_TOKEN=<agent token> \
|
||||
vash-tv
|
||||
```
|
||||
|
||||
For gameplay from a Windows PC, the simplest setup is osu!tourney, tosu, OBS, and the Vash streamer agent from sections 2–3. Stream directly from OBS to your platform. In that setup, the site's Go live switch and on-air status do not control OBS. If you also want the site's watch page, recording, and destinations, run Vash TV in relay mode on the same PC and send OBS to its local RTMP input:
|
||||
|
||||
```sh
|
||||
docker run -d --restart unless-stopped --name vash-relay -p 127.0.0.1:1935:1935 \
|
||||
-e VASH_SERVER=https://esports.vash.software -e VASH_CHANNEL=<channel id> \
|
||||
-e VASH_TOKEN=<agent token> -e INGEST_KEY=<private ingest key> vash-tv
|
||||
```
|
||||
|
||||
In OBS, set a **Custom** stream server to `rtmp://127.0.0.1:1935/live` and use the same private ingest key. Start the container, press **Go live** on the Stream page, then start streaming in OBS. Stop OBS and press **End stream** when finished.
|
||||
|
||||
| Variable | |
|
||||
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `VASH_SERVER`, `VASH_CHANNEL`, `VASH_TOKEN` | The site, the channel, and its agent token. Everything else comes from the channel's settings. |
|
||||
| `INGEST_KEY` | Relay mode: instead of rendering, listen for OBS on `rtmp://<host>:1935/live` with this key (`INGEST_PORT` to change the port). What OBS sends is recorded and sent to every destination unchanged. Use it for the full tourney broadcast so it is recorded and multistreamed too: in OBS, _Settings → Stream → Custom_, server `rtmp://<host>:1935/live`, key `INGEST_KEY`. |
|
||||
| `OVERLAY_ORIGIN` | Load the overlay from another address, e.g. `http://127.0.0.1:3102` when the container runs next to a dev site behind a login. |
|
||||
| `MUSIC_URL` | An audio file or stream to loop under the broadcast (silence otherwise). |
|
||||
| `OVERLAY_URL` + `RTMP_URL` | Without Vash managing it: one overlay to one RTMP destination (`WIDTH`, `HEIGHT`, `FPS`, `VIDEO_BITRATE` apply). |
|
||||
|
||||
Measured on the Vash VM, 720p30 uses about half a CPU core and 510 MB of memory; 1080p and busy scenes use more. Give it 2 vCPUs and 2 GB, and keep it off the Vash server. Upload bandwidth is the bitrate once per destination, plus once for the recording.
|
||||
|
||||
**Google Cloud** (e2-medium, about $25 a month). Container-Optimized OS comes with Docker:
|
||||
|
||||
```sh
|
||||
gcloud compute instances create vash-tv --machine-type=e2-medium --zone=europe-west4-a \
|
||||
--image-family=cos-stable --image-project=cos-cloud \
|
||||
--metadata=startup-script='docker run -d --restart unless-stopped --shm-size=1g --name vash-tv \
|
||||
-e VASH_SERVER=https://esports.vash.software -e VASH_CHANNEL=<id> -e VASH_TOKEN=<token> <registry>/vash-tv'
|
||||
```
|
||||
|
||||
**AWS:** an EC2 `t3.small` with Docker and the `docker run` above (about $15 a month), or an ECS Fargate task with 2 vCPU and 4 GB (about $70 a month running full time). For relay mode, open TCP 1935 to your streaming PC only.
|
||||
|
||||
Stop the instance when nothing is scheduled; the channel's recordings stay in storage.
|
||||
|
||||
## 6. Autonomous osu!tourney container
|
||||
|
||||
The `streamer/tourney` image adds a separate virtual screen for the stable osu!tourney client, starts it under Wine, reads its scores with tosu, and keys the Vash overlay over the gameplay in ffmpeg. The same encoder sends to all enabled destinations and records to the site. The director selects the room automatically from osu!tourney's control panel. It retries while the room is still opening.
|
||||
|
||||
osu!tourney needs a dedicated osu! stable installation and an osu! account with an active supporter tag. Use an account you are allowed to operate, and sign in once with **Remember Username** and **Remember Password**. Set `OSU_TOURNEY_ACCOUNT` to that username in the Vash server environment so each new lobby grants it referee access. The container does not store an osu! password in Vash. All new Vash lobbies use the `VASH: (Red) vs (Blue)` naming required by this single osu!tourney installation. The agent restarts osu!tourney when a match needs a different `TeamSize`; it never edits `tournament.cfg` while the client is running.
|
||||
|
||||
Build the image from the repository root:
|
||||
|
||||
```sh
|
||||
docker build -f streamer/tourney/Dockerfile -t vash-tourney streamer
|
||||
```
|
||||
|
||||
Prepare a persistent directory owned by uid 1000. Place a **fresh, separate** osu! stable installation at `/path/to/vash-tourney-data/osu/osu!.exe` and the executable Linux build of [tosu](https://github.com/tosuapp/tosu/releases) at `/path/to/vash-tourney-data/tosu/tosu`. The tosu project documents Wine support; this setup still needs a real lobby test on your host. A GPU host is recommended until performance is measured for your team size and resolution.
|
||||
|
||||
First sign in to osu! in normal mode. For a fresh Wine prefix, `OSU_DOTNET=1` installs .NET Framework 4.8 with winetricks before launching osu!; the installer may take several minutes and download Microsoft files. If the mounted prefix already has a working .NET installation, omit that variable. Use a strong temporary VNC password and keep the web port bound to localhost, then reach it through SSH forwarding if the Docker host is remote:
|
||||
|
||||
```sh
|
||||
docker run --rm --shm-size=1g --cap-add SYS_PTRACE \
|
||||
-v /path/to/vash-tourney-data:/data -p 127.0.0.1:6080:6080 \
|
||||
-e OSU_SETUP=1 -e OSU_DOTNET=1 -e VNC_PASSWORD='<private password>' vash-tourney
|
||||
# Open http://127.0.0.1:6080/vnc.html, sign in to osu!, then stop the container.
|
||||
```
|
||||
|
||||
Create a broadcast channel with the **Over osu! gameplay** layout, add Twitch, YouTube, or other destinations, and copy its one-time agent token. Then run:
|
||||
|
||||
```sh
|
||||
docker run -d --restart unless-stopped --shm-size=1g --cap-add SYS_PTRACE \
|
||||
--name vash-tourney -v /path/to/vash-tourney-data:/data \
|
||||
-p 127.0.0.1:6080:6080 \
|
||||
-e VNC_PASSWORD='<private password>' \
|
||||
-e VASH_SERVER=https://esports.vash.software \
|
||||
-e VASH_CHANNEL='<channel id>' -e VASH_TOKEN='<agent token>' \
|
||||
vash-tourney
|
||||
```
|
||||
|
||||
The Go live switch controls ffmpeg and destinations; the tourney client stays ready to follow matches. Watch `docker logs -f vash-tourney` for room selection and tosu connection. The VNC screen shows the osu!tourney control panel if it needs attention. Live scores appear when tosu reads the spectated clients. The container captures the top `1280×720` area of osu!tourney and the bottom control panel only for room selection. Set `GAMEPLAY_WIDTH` and `GAMEPLAY_HEIGHT` to change that capture size, and use the matching channel quality. The compositor also supports portrait outputs, with the 16:9 gameplay letterboxed under the portrait overlay.
|
||||
|
||||
This uses the osu!tourney room naming and slot rules described in the [official multiplayer guide](https://osu.ppy.sh/wiki/en/osu!_tournament_client/osu!tourney/Multiplayer_usage) and the [official setup guide](https://osu.ppy.sh/wiki/en/osu!_tournament_client/osu!tourney/Setup). The IPC file is only needed for the separate osu!(lazer) tournament overlay; this container draws the Vash overlay itself.
|
||||
99
streamer/WSL.md
Normal file
99
streamer/WSL.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Test a match from Windows 11 + WSL Ubuntu
|
||||
|
||||
Use Ubuntu for the repository, builds, and optional Docker relay. Run osu!tourney, tosu, OBS, and the compiled Windows agent on Windows. This uses OBS's native AMD encoder on an RX 7900 XT. The Vash web server and its IRC host stay on the existing server; this PC only runs the broadcast tools.
|
||||
|
||||
WSL can [launch Windows executables directly](https://learn.microsoft.com/en-us/windows/wsl/filesystems#run-windows-tools-from-linux). Using `vash-streamer.exe` means its `127.0.0.1` addresses refer to Windows, where OBS and tosu are running.
|
||||
|
||||
## 1. Clone and build in Ubuntu
|
||||
|
||||
If Ubuntu is not installed yet, run `wsl --install -d Ubuntu` in an administrator PowerShell window, restart if prompted, and finish Ubuntu's first-run setup. Open Ubuntu with `wsl -d Ubuntu`.
|
||||
|
||||
Inside Ubuntu:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y git curl unzip
|
||||
curl -fsSL https://bun.com/install | bash
|
||||
source ~/.bashrc
|
||||
|
||||
mkdir -p ~/dev
|
||||
cd ~/dev
|
||||
git clone --branch dev https://git.stanrunge.dev/stan/esports.git
|
||||
cd esports
|
||||
bun install --frozen-lockfile
|
||||
bun run streamer:build
|
||||
```
|
||||
|
||||
The build produces `dist/vash-streamer.exe` and `dist/vash-streamer`. Use the `.exe` for this setup. A streaming-only clone needs no server `.env`, database, or `bun run dev`.
|
||||
|
||||
## 2. Prepare the Windows programs
|
||||
|
||||
1. Make a separate osu! stable installation for osu!tourney and follow the [official setup guide](https://osu.ppy.sh/wiki/en/osu!_tournament_client/osu!tourney/Setup). Sign in with an account you are allowed to operate that has osu!supporter. The spectating account must differ from the account playing the match.
|
||||
2. Put this `tournament.cfg` next to that installation's `osu!.exe` for a 1v1 test, then launch it:
|
||||
|
||||
```ini
|
||||
TeamSize = 1
|
||||
acronym = VASH
|
||||
```
|
||||
|
||||
3. Start the Windows build of [tosu](https://github.com/tosuapp/tosu/releases). Its WebSocket should be available at `ws://127.0.0.1:24050/websocket/v2`.
|
||||
4. Start OBS. Enable **Tools → WebSocket Server Settings → Enable WebSocket server**, keep port **4455**, and copy its password. Set a 1920×1080 canvas and choose the AMD H.264 encoder for streaming/recording.
|
||||
|
||||
## 3. Connect a broadcast channel
|
||||
|
||||
On **https://esports-dev.vash.software/broadcast**, create a channel using **Over osu! gameplay**. Choose **One match** for a specific test, or the automatic mode while setting up. Copy the channel ID and its agent token. The **Agent token** button on the Broadcast list generates a new token and invalidates the old one; use the same current token for this agent and the optional relay.
|
||||
|
||||
In Ubuntu, from the repository root, replace the three placeholders:
|
||||
|
||||
```bash
|
||||
./dist/vash-streamer.exe \
|
||||
--server https://esports-dev.vash.software \
|
||||
--channel 'CHANNEL_ID' --token 'AGENT_TOKEN' \
|
||||
--obs-password 'OBS_WEBSOCKET_PASSWORD' --setup-obs
|
||||
```
|
||||
|
||||
This creates a **Vash** scene, an osu! capture source, and the overlay. In OBS, open the capture source's properties and select the osu!tourney window. Arrange the gameplay beneath the overlay. The setup command exits; now start the agent and leave it running:
|
||||
|
||||
```bash
|
||||
./dist/vash-streamer.exe \
|
||||
--server https://esports-dev.vash.software \
|
||||
--channel 'CHANNEL_ID' --token 'AGENT_TOKEN' \
|
||||
--obs-password 'OBS_WEBSOCKET_PASSWORD'
|
||||
```
|
||||
|
||||
## 4. Test one match
|
||||
|
||||
1. Create one real-lobby match in Vash's simulation lab. The match page should show a lobby link and move from opening to waiting for players.
|
||||
2. Point the broadcast channel at that match. Join the lobby with your playing account. In osu!tourney's lower control panel, select the matching `VASH: (Red) vs (Blue)` room. Windows room selection is manual.
|
||||
3. For password/referee access, configure `OSU_TOURNEY_ACCOUNT` on the Vash server to the spectating username before creating the lobby. The host grants it referee access during setup.
|
||||
4. Start with OBS **Start Recording** or its preview. Play a map and check the overlay, agent connection, and live score on the match page. A simulated opponent exists inside Vash and has no real gameplay window to spectate.
|
||||
5. To stream directly, configure the destination in OBS and press **Start Streaming**. OBS controls this stream; the site's **Go live** button controls the optional relay/container.
|
||||
|
||||
If the agent gets an HTTP login page or WebSocket 403 instead of connecting, check any access proxy in front of the dev site. Browser login cookies are not inherited by the standalone agent.
|
||||
|
||||
## 5. Optional: site playback, recording, and multistream
|
||||
|
||||
Install Docker Desktop on Windows. Enable **Use the WSL 2 based engine** and **Settings → Resources → WSL Integration → Ubuntu** ([Docker instructions](https://docs.docker.com/desktop/features/wsl/)). Then, in Ubuntu:
|
||||
|
||||
```bash
|
||||
docker version
|
||||
docker build -t vash-tv streamer/tv
|
||||
docker run -d --name vash-relay --restart unless-stopped \
|
||||
-p 127.0.0.1:1935:1935 \
|
||||
-e VASH_SERVER=https://esports-dev.vash.software \
|
||||
-e VASH_CHANNEL='CHANNEL_ID' -e VASH_TOKEN='AGENT_TOKEN' \
|
||||
-e INGEST_KEY='CHOOSE_A_PRIVATE_KEY' vash-tv
|
||||
docker ps --filter name=vash-relay
|
||||
docker logs -f vash-relay
|
||||
```
|
||||
|
||||
In OBS, choose **Settings → Stream → Custom**:
|
||||
|
||||
- Server: `rtmp://127.0.0.1:1935/live`
|
||||
- Stream key: the `INGEST_KEY` value above
|
||||
|
||||
Configure destinations on the channel's Stream page, press **Go live**, then **Start Streaming** in OBS. The relay forwards OBS's encoded stream and uploads the recording when server storage is configured. It does not require GPU access. Stop OBS and press **End stream** when finished; `docker stop vash-relay` stops the relay. Use `docker start vash-relay` next time.
|
||||
|
||||
## Running the entire tourney container in WSL
|
||||
|
||||
The [Wine/container setup](README.md#6-autonomous-osutourney-container) can also be built and launched from WSL's Docker CLI, but it still needs osu! installation, login, and a real gameplay test on that host. The current image uses Xvfb and CPU encoding; it has no configured AMD GPU path. Docker Desktop's documented GPU support is [NVIDIA GPU-PV](https://docs.docker.com/desktop/features/gpu/). Windows OBS is the recommended first test for this AMD machine.
|
||||
264
streamer/agent.ts
Normal file
264
streamer/agent.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* vash-streamer: runs next to OBS and the osu! tourney client on the
|
||||
* streaming PC, and connects them to a Vash broadcast channel.
|
||||
*
|
||||
* tosu ──live scores──▶ agent ──▶ Vash (/ws/agent) ──▶ overlays, match page
|
||||
* Vash ──scene, lobby──▶ agent ──▶ OBS scenes, tourney client (#mp_…)
|
||||
*
|
||||
* Every part is optional except Vash itself: without tosu there are no live
|
||||
* scores from real lobbies, without OBS nothing switches scenes.
|
||||
*
|
||||
* vash-streamer --server https://esports.vash.software --channel <id> --token <token>
|
||||
* vash-streamer ... --obs-password <password> --setup-obs create a ready OBS scene
|
||||
*
|
||||
* Build single binaries with `bun run streamer:build`.
|
||||
*/
|
||||
import { parseArgs } from 'node:util';
|
||||
import { Obs } from './obs';
|
||||
import { parseTosu, type TosuFrame } from './tosu';
|
||||
import { Tourney } from './tourney';
|
||||
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
server: { type: 'string', default: process.env.VASH_SERVER ?? 'https://esports.vash.software' },
|
||||
channel: { type: 'string', default: process.env.VASH_CHANNEL },
|
||||
token: { type: 'string', default: process.env.VASH_TOKEN },
|
||||
tosu: { type: 'string', default: process.env.TOSU_URL ?? 'ws://127.0.0.1:24050/websocket/v2' },
|
||||
obs: { type: 'string', default: process.env.OBS_URL ?? 'ws://127.0.0.1:4455' },
|
||||
'obs-password': { type: 'string', default: process.env.OBS_PASSWORD ?? '' },
|
||||
/** JSON: Vash scene → OBS scene name, e.g. {"gameplay":"Gameplay","idle":"Starting Soon"} */
|
||||
'obs-scenes': { type: 'string', default: process.env.OBS_SCENES ?? '' },
|
||||
'setup-obs': { type: 'boolean', default: false },
|
||||
'auto-join': { type: 'boolean', default: process.env.AUTO_JOIN === '1' },
|
||||
'osu-exe': { type: 'string', default: process.env.OSU_EXE ?? '' },
|
||||
help: { type: 'boolean', short: 'h', default: false }
|
||||
}
|
||||
});
|
||||
|
||||
if (args.help || !args.channel || !args.token) {
|
||||
console.log(`vash-streamer: connects tosu and OBS to a Vash broadcast channel.
|
||||
|
||||
--server URL Vash site (default https://esports.vash.software)
|
||||
--channel ID broadcast channel id (or VASH_CHANNEL)
|
||||
--token TOKEN the channel's agent token (or VASH_TOKEN)
|
||||
--tosu URL tosu websocket (default ws://127.0.0.1:24050/websocket/v2)
|
||||
--obs URL obs-websocket (default ws://127.0.0.1:4455)
|
||||
--obs-password PW obs-websocket password
|
||||
--obs-scenes JSON switch OBS scenes with the match, e.g. '{"gameplay":"Gameplay","results":"Results"}'
|
||||
--setup-obs create a "Vash" scene with the overlay and a tourney client capture, then exit
|
||||
--auto-join select the match in osu!tourney's control panel (Wine/X11)
|
||||
--osu-exe PATH launch osu!tourney from this executable under Wine`);
|
||||
process.exit(args.help ? 0 : 1);
|
||||
}
|
||||
|
||||
const server = args.server!.replace(/\/$/, '');
|
||||
const channel = args.channel!;
|
||||
const overlayUrl = `${server}/broadcast/${channel}/overlay`;
|
||||
const say = (...parts: unknown[]) => console.log(new Date().toISOString().slice(11, 19), ...parts);
|
||||
|
||||
// ── OBS ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const obs = new Obs(args.obs!, args['obs-password']!);
|
||||
const sceneMap: Record<string, string> = args['obs-scenes'] ? JSON.parse(args['obs-scenes']) : {};
|
||||
|
||||
if (args['setup-obs']) {
|
||||
await obs.connect();
|
||||
const { scenes } = await obs.request<{ scenes: { sceneName: string }[] }>('GetSceneList');
|
||||
if (!scenes.some((scene) => scene.sceneName === 'Vash'))
|
||||
await obs.request('CreateScene', { sceneName: 'Vash' });
|
||||
// Inputs added later sit on top: the capture first, then the see-through overlay over it.
|
||||
const kind =
|
||||
process.platform === 'win32'
|
||||
? 'window_capture'
|
||||
: process.platform === 'darwin'
|
||||
? 'screen_capture'
|
||||
: 'xcomposite_input';
|
||||
await obs
|
||||
.request('CreateInput', {
|
||||
sceneName: 'Vash',
|
||||
inputName: 'osu! tourney client',
|
||||
inputKind: kind,
|
||||
inputSettings: {}
|
||||
})
|
||||
.catch((err) => say('capture:', err.message));
|
||||
await obs
|
||||
.request('CreateInput', {
|
||||
sceneName: 'Vash',
|
||||
inputName: 'Vash overlay',
|
||||
inputKind: 'browser_source',
|
||||
inputSettings: {
|
||||
url: overlayUrl,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps_custom: true,
|
||||
fps: 60,
|
||||
reroute_audio: true
|
||||
}
|
||||
})
|
||||
.catch((err) => say('overlay:', err.message));
|
||||
await obs.request('SetCurrentProgramScene', { sceneName: 'Vash' });
|
||||
say('OBS is set up: scene "Vash" with the overlay on top of "osu! tourney client".');
|
||||
say('Open that capture source and pick the tourney client window.');
|
||||
obs.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
async function connectObs() {
|
||||
if (!Object.keys(sceneMap).length || obs.connected) return;
|
||||
try {
|
||||
await obs.connect();
|
||||
say('OBS connected');
|
||||
} catch (err) {
|
||||
say((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vash ────────────────────────────────────────────────────────────────
|
||||
|
||||
let vash: WebSocket | null = null;
|
||||
let vashAttempts = 0;
|
||||
let current: { scene: string; matchId: string | null; lobbyId: number | null } = {
|
||||
scene: 'idle',
|
||||
matchId: null,
|
||||
lobbyId: null
|
||||
};
|
||||
const tourney = args['osu-exe']
|
||||
? new Tourney(args['osu-exe'], {
|
||||
display: process.env.GAMEPLAY_DISPLAY ?? ':98',
|
||||
width: Number(process.env.GAMEPLAY_WIDTH ?? 1280),
|
||||
height: Number(process.env.GAMEPLAY_HEIGHT ?? 720)
|
||||
})
|
||||
: null;
|
||||
let roomName: string | null = null;
|
||||
let teamSize = 1;
|
||||
let selectedLobby: number | null = null;
|
||||
let lastJoinNotice = 0;
|
||||
|
||||
function connectVash() {
|
||||
const url = `${server.replace(/^http/, 'ws')}/ws/agent`;
|
||||
const socket = new WebSocket(url);
|
||||
vash = socket;
|
||||
socket.onopen = () =>
|
||||
socket.send(JSON.stringify({ type: 'hello', channelId: channel, token: args.token }));
|
||||
socket.onmessage = (event) => {
|
||||
const message = JSON.parse(String(event.data));
|
||||
if (message.type === 'welcome') {
|
||||
vashAttempts = 0;
|
||||
say(`Connected to ${server} as channel "${message.channel}". Overlay: ${overlayUrl}`);
|
||||
} else if (message.type === 'follow') void follow(message);
|
||||
};
|
||||
socket.onclose = (event) => {
|
||||
if (event.code === 4001) {
|
||||
say('Vash refused the channel or token. Check them on the Broadcast page.');
|
||||
process.exit(1);
|
||||
}
|
||||
const wait = Math.min(30_000, 1000 * 2 ** vashAttempts++);
|
||||
say(`Lost Vash (${event.code}); retrying in ${Math.round(wait / 1000)}s`);
|
||||
setTimeout(connectVash, wait);
|
||||
};
|
||||
}
|
||||
|
||||
async function follow(next: {
|
||||
scene: string;
|
||||
matchId: string | null;
|
||||
lobbyId: number | null;
|
||||
title: string | null;
|
||||
roomName?: string | null;
|
||||
teamSize?: number | null;
|
||||
}) {
|
||||
const changedMatch = next.matchId !== current.matchId;
|
||||
const changedLobby = next.lobbyId != null && next.lobbyId !== current.lobbyId;
|
||||
if (changedMatch)
|
||||
say(
|
||||
next.title
|
||||
? `Now showing ${next.title}${next.lobbyId ? ` (mp/${next.lobbyId})` : ''}`
|
||||
: 'Nothing to show; idle'
|
||||
);
|
||||
if (next.scene !== current.scene) say(`Scene: ${next.scene}`);
|
||||
const sceneName = sceneMap[next.scene];
|
||||
if (sceneName && next.scene !== current.scene) {
|
||||
await connectObs();
|
||||
await obs.request('SetCurrentProgramScene', { sceneName }).catch((err) => say(err.message));
|
||||
}
|
||||
if (changedLobby) selectedLobby = null;
|
||||
roomName = next.roomName ?? null;
|
||||
teamSize = next.teamSize ?? 1;
|
||||
if (changedLobby && args['auto-join'] && !tourney)
|
||||
say('Automatic room selection needs --osu-exe on a Wine/X11 host.');
|
||||
current = { scene: next.scene, matchId: next.matchId, lobbyId: next.lobbyId };
|
||||
}
|
||||
|
||||
if (tourney && args['auto-join']) {
|
||||
let selecting = false;
|
||||
setInterval(async () => {
|
||||
if (selecting || !current.lobbyId || !roomName) return;
|
||||
selecting = true;
|
||||
try {
|
||||
if (await tourney.ensure(teamSize)) selectedLobby = null;
|
||||
if (selectedLobby === current.lobbyId) return;
|
||||
if (await tourney.select(roomName)) {
|
||||
selectedLobby = current.lobbyId;
|
||||
say(`osu!tourney is spectating ${roomName} (mp/${selectedLobby})`);
|
||||
} else if (Date.now() - lastJoinNotice > 30_000) {
|
||||
lastJoinNotice = Date.now();
|
||||
say(`Waiting for ${roomName} in osu!tourney's room list`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (Date.now() - lastJoinNotice > 30_000) {
|
||||
lastJoinNotice = Date.now();
|
||||
say(`osu!tourney: ${(error as Error).message}`);
|
||||
}
|
||||
} finally {
|
||||
selecting = false;
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// ── tosu ────────────────────────────────────────────────────────────────
|
||||
|
||||
let tosuAttempts = 0;
|
||||
let lastSent = 0;
|
||||
let lastKey = '';
|
||||
let pending: TosuFrame | null = null;
|
||||
|
||||
function connectTosu() {
|
||||
const socket = new WebSocket(args.tosu!);
|
||||
socket.onopen = () => {
|
||||
tosuAttempts = 0;
|
||||
say('tosu connected');
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
const frame = parseTosu(JSON.parse(String(event.data)));
|
||||
if (!frame || !frame.clients.length) return;
|
||||
pending = frame;
|
||||
flush();
|
||||
};
|
||||
socket.onclose = () => {
|
||||
// tosu is often started after the agent; keep trying quietly.
|
||||
const wait = Math.min(15_000, 1000 * 2 ** tosuAttempts++);
|
||||
if (tosuAttempts === 3)
|
||||
say(`tosu is not running at ${args.tosu}; live scores will start when it is.`);
|
||||
setTimeout(connectTosu, wait);
|
||||
};
|
||||
}
|
||||
|
||||
/** At most four updates a second, and only when something changed. */
|
||||
function flush() {
|
||||
if (!pending || vash?.readyState !== WebSocket.OPEN) return;
|
||||
const wait = 250 - (Date.now() - lastSent);
|
||||
if (wait > 0) {
|
||||
setTimeout(flush, wait);
|
||||
return;
|
||||
}
|
||||
const frame = pending;
|
||||
pending = null;
|
||||
const key = frame.clients.map((client) => `${client.osuId}:${client.score}`).join('|');
|
||||
if (key === lastKey) return;
|
||||
lastKey = key;
|
||||
lastSent = Date.now();
|
||||
vash.send(JSON.stringify({ type: 'tosu', progress: frame.progress, clients: frame.clients }));
|
||||
}
|
||||
|
||||
connectVash();
|
||||
connectTosu();
|
||||
38
streamer/obs.test.ts
Normal file
38
streamer/obs.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { obsAuthentication } from './obs';
|
||||
import { parseTosu } from './tosu';
|
||||
|
||||
test('OBS authentication matches the protocol documentation example', () => {
|
||||
expect(
|
||||
obsAuthentication(
|
||||
'supersecretpassword',
|
||||
'lM1GncleQOaCu9lT1yeUZhFYnqhsLLP1G5lAGo3ixaI=',
|
||||
'+IxH4CnCiqpX1rM9scsNynZzbOe4KhDeYcTNS3PDaeY='
|
||||
)
|
||||
).toBe('1Ct943GAT+6YQUUX47Ia/ncufilbe6+oD6lY+5kaCu4=');
|
||||
});
|
||||
|
||||
test('tosu tourney frames become per-player scores with map progress', () => {
|
||||
const frame = parseTosu({
|
||||
beatmap: { id: 75, time: { live: 30_000, firstObject: 10_000, lastObject: 50_000 } },
|
||||
tourney: {
|
||||
ipcState: 3,
|
||||
clients: [
|
||||
{
|
||||
user: { id: 2, name: 'peppy' },
|
||||
play: { score: 123_456, accuracy: 98.5, failed: false, combo: { current: 321 } }
|
||||
},
|
||||
{ user: { id: 0, name: '' }, play: { score: 0 } }
|
||||
]
|
||||
}
|
||||
});
|
||||
expect(frame).toEqual({
|
||||
ipcState: 3,
|
||||
beatmapId: 75,
|
||||
progress: 0.5,
|
||||
clients: [
|
||||
{ osuId: 2, name: 'peppy', score: 123_456, accuracy: 98.5, combo: 321, failed: false }
|
||||
]
|
||||
});
|
||||
expect(parseTosu({ beatmap: {} })).toBeNull();
|
||||
});
|
||||
87
streamer/obs.ts
Normal file
87
streamer/obs.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Just enough of obs-websocket 5 (built into OBS 28+) to switch scenes and
|
||||
* set up sources: https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md
|
||||
*/
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
|
||||
const sha256 = (text: string) => createHash('sha256').update(text).digest('base64');
|
||||
|
||||
/** The challenge-response from the protocol's Authentication section. */
|
||||
export function obsAuthentication(password: string, salt: string, challenge: string) {
|
||||
return sha256(sha256(password + salt) + challenge);
|
||||
}
|
||||
|
||||
export class Obs {
|
||||
private socket: WebSocket | null = null;
|
||||
private pending = new Map<string, { resolve(data: unknown): void; reject(err: Error): void }>();
|
||||
|
||||
constructor(
|
||||
private url: string,
|
||||
private password: string
|
||||
) {}
|
||||
|
||||
get connected() {
|
||||
return this.socket?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = new WebSocket(this.url);
|
||||
this.socket = socket;
|
||||
socket.onerror = () =>
|
||||
reject(
|
||||
new Error(
|
||||
`Could not reach OBS at ${this.url}. Is Tools → WebSocket Server Settings enabled?`
|
||||
)
|
||||
);
|
||||
socket.onclose = () => {
|
||||
for (const waiter of this.pending.values()) waiter.reject(new Error('OBS disconnected'));
|
||||
this.pending.clear();
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
const { op, d } = JSON.parse(String(event.data));
|
||||
if (op === 0) {
|
||||
const auth = d.authentication as { salt: string; challenge: string } | undefined;
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
op: 1,
|
||||
d: {
|
||||
rpcVersion: 1,
|
||||
eventSubscriptions: 0,
|
||||
...(auth
|
||||
? { authentication: obsAuthentication(this.password, auth.salt, auth.challenge) }
|
||||
: {})
|
||||
}
|
||||
})
|
||||
);
|
||||
} else if (op === 2) resolve();
|
||||
else if (op === 7) {
|
||||
const waiter = this.pending.get(d.requestId);
|
||||
this.pending.delete(d.requestId);
|
||||
if (d.requestStatus?.result) waiter?.resolve(d.responseData);
|
||||
else
|
||||
waiter?.reject(
|
||||
new Error(
|
||||
`OBS ${d.requestType}: ${d.requestStatus?.comment ?? d.requestStatus?.code}`
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
request<T = unknown>(requestType: string, requestData: Record<string, unknown> = {}): Promise<T> {
|
||||
const socket = this.socket;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN)
|
||||
return Promise.reject(new Error('OBS is not connected'));
|
||||
const requestId = randomUUID();
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.pending.set(requestId, { resolve: resolve as (data: unknown) => void, reject });
|
||||
socket.send(JSON.stringify({ op: 6, d: { requestType, requestId, requestData } }));
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.socket?.close();
|
||||
}
|
||||
}
|
||||
67
streamer/tosu.ts
Normal file
67
streamer/tosu.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* tosu (https://github.com/tosuapp/tosu) reads the osu! tourney client's
|
||||
* memory and serves it over a websocket. We need each spectated player's
|
||||
* live score, and how far through the map they are.
|
||||
*/
|
||||
|
||||
export interface TosuClientScore {
|
||||
osuId: number;
|
||||
name: string;
|
||||
score: number;
|
||||
/** Percent. */
|
||||
accuracy: number;
|
||||
combo: number;
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
export interface TosuFrame {
|
||||
/** 1 idle, 2 waiting for clients, 3 playing, 4 results (tosu's ipcState). */
|
||||
ipcState: number | null;
|
||||
beatmapId: number | null;
|
||||
/** 0–1 through the map. */
|
||||
progress: number | null;
|
||||
clients: TosuClientScore[];
|
||||
}
|
||||
|
||||
/** Picks what Vash needs out of one tosu v2 message. Returns null outside tourney mode. */
|
||||
export function parseTosu(message: unknown): TosuFrame | null {
|
||||
const root = message as {
|
||||
tourney?: {
|
||||
ipcState?: number;
|
||||
clients?: {
|
||||
user?: { id?: number; name?: string };
|
||||
play?: {
|
||||
score?: number;
|
||||
accuracy?: number;
|
||||
failed?: boolean;
|
||||
combo?: { current?: number };
|
||||
};
|
||||
}[];
|
||||
};
|
||||
beatmap?: { id?: number; time?: { live?: number; firstObject?: number; lastObject?: number } };
|
||||
};
|
||||
if (!root?.tourney || !Array.isArray(root.tourney.clients)) return null;
|
||||
const time = root.beatmap?.time;
|
||||
const span =
|
||||
time && time.lastObject != null && time.firstObject != null
|
||||
? time.lastObject - time.firstObject
|
||||
: 0;
|
||||
return {
|
||||
ipcState: root.tourney.ipcState ?? null,
|
||||
beatmapId: root.beatmap?.id ?? null,
|
||||
progress:
|
||||
span > 0 && time?.live != null
|
||||
? Math.max(0, Math.min(1, (time.live - time.firstObject!) / span))
|
||||
: null,
|
||||
clients: root.tourney.clients
|
||||
.filter((client) => (client.user?.id ?? 0) > 0)
|
||||
.map((client) => ({
|
||||
osuId: client.user!.id!,
|
||||
name: client.user?.name ?? '',
|
||||
score: client.play?.score ?? 0,
|
||||
accuracy: client.play?.accuracy ?? 0,
|
||||
combo: client.play?.combo?.current ?? 0,
|
||||
failed: client.play?.failed ?? false
|
||||
}))
|
||||
};
|
||||
}
|
||||
23
streamer/tourney.test.ts
Normal file
23
streamer/tourney.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { findRoom } from './tourney';
|
||||
|
||||
const header =
|
||||
'level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\tleft\ttop\twidth\theight\tconf\ttext';
|
||||
const word = (line: number, index: number, left: number, text: string) =>
|
||||
`5\t1\t1\t1\t${line}\t${index}\t${left}\t${line * 30}\t${text.length * 8}\t16\t90\t${text}`;
|
||||
|
||||
test('selects the named tourney room, not the first room in the panel', () => {
|
||||
const tsv = [
|
||||
header,
|
||||
word(1, 1, 20, 'VASH:'),
|
||||
word(1, 2, 85, '(Other)'),
|
||||
word(1, 3, 160, 'vs'),
|
||||
word(1, 4, 190, '(Player)'),
|
||||
word(2, 1, 20, 'VASH:'),
|
||||
word(2, 2, 85, '(Alpha)'),
|
||||
word(2, 3, 160, 'vs'),
|
||||
word(2, 4, 190, '(Beta)')
|
||||
].join('\n');
|
||||
expect(findRoom(tsv, 'VASH: (Alpha) vs (Beta)')).toEqual({ x: 129, y: 68 });
|
||||
expect(findRoom(tsv, 'VASH: (Missing) vs (Beta)')).toBeNull();
|
||||
});
|
||||
157
streamer/tourney.ts
Normal file
157
streamer/tourney.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/** Launches osu!tourney under Wine and selects a room from its control panel. */
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import type { Subprocess } from 'bun';
|
||||
|
||||
interface Word {
|
||||
line: string;
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
const plain = (value: string) =>
|
||||
value
|
||||
.normalize('NFKD')
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]/gu, '');
|
||||
|
||||
/** OCR is imperfect, so require both team names on one control-panel line. */
|
||||
export function findRoom(tsv: string, roomName: string): { x: number; y: number } | null {
|
||||
const names = [...roomName.matchAll(/\(([^)]+)\)/g)].map((match) => plain(match[1]));
|
||||
if (names.length !== 2 || names.some((name) => !name)) return null;
|
||||
const words: Word[] = tsv
|
||||
.split('\n')
|
||||
.slice(1)
|
||||
.map((row) => row.split('\t'))
|
||||
.filter((row) => row.length >= 12 && row[0] === '5' && Number(row[10]) >= 30)
|
||||
.map((row) => ({
|
||||
line: row.slice(1, 5).join(':'),
|
||||
text: row.slice(11).join(''),
|
||||
x: Number(row[6]),
|
||||
y: Number(row[7]),
|
||||
w: Number(row[8]),
|
||||
h: Number(row[9])
|
||||
}));
|
||||
const lines = new Map<string, Word[]>();
|
||||
for (const word of words) lines.set(word.line, [...(lines.get(word.line) ?? []), word]);
|
||||
for (const line of lines.values()) {
|
||||
const normalized = plain(line.map((word) => word.text).join(''));
|
||||
if (!normalized.includes(`vash${names[0]}vs${names[1]}`)) continue;
|
||||
const left = Math.min(...line.map((word) => word.x));
|
||||
const right = Math.max(...line.map((word) => word.x + word.w));
|
||||
const top = Math.min(...line.map((word) => word.y));
|
||||
const bottom = Math.max(...line.map((word) => word.y + word.h));
|
||||
return { x: Math.round((left + right) / 2), y: Math.round((top + bottom) / 2) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tournamentConfig(existing: string, teamSize: number, height: number) {
|
||||
const lines = existing
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => !/^\s*(TeamSize|acronym|Height)\s*=/i.test(line));
|
||||
return [
|
||||
...lines.filter(Boolean),
|
||||
`TeamSize = ${teamSize}`,
|
||||
'acronym = VASH',
|
||||
`Height = ${height}`,
|
||||
''
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export class Tourney {
|
||||
private process: Subprocess | null = null;
|
||||
private size = 0;
|
||||
private startedAt = 0;
|
||||
private readonly exe: string;
|
||||
private readonly directory: string;
|
||||
private readonly display: string;
|
||||
private readonly width: number;
|
||||
private readonly height: number;
|
||||
|
||||
constructor(exe: string, options: { display: string; width: number; height: number }) {
|
||||
this.exe = exe;
|
||||
this.directory = dirname(exe);
|
||||
this.display = options.display;
|
||||
this.width = options.width;
|
||||
this.height = options.height;
|
||||
}
|
||||
|
||||
/** Config must be written while osu! is closed; a size change restarts it. */
|
||||
async ensure(teamSize: number) {
|
||||
if (!Number.isInteger(teamSize) || teamSize < 1 || teamSize > 8)
|
||||
throw new Error(`Unsupported team size ${teamSize}`);
|
||||
if (this.size === teamSize && this.process?.exitCode == null) return false;
|
||||
if (this.process) {
|
||||
this.process.kill();
|
||||
await Bun.spawn(['wineserver', '-k'], { stdout: 'ignore', stderr: 'ignore' }).exited;
|
||||
await this.process.exited;
|
||||
this.process = null;
|
||||
}
|
||||
const configPath = join(this.directory, 'tournament.cfg');
|
||||
const existing = await readFile(configPath, 'utf8').catch(() => '');
|
||||
await writeFile(configPath, tournamentConfig(existing, teamSize, this.height));
|
||||
this.process = Bun.spawn(['wine', this.exe], {
|
||||
cwd: this.directory,
|
||||
env: { ...process.env, DISPLAY: this.display },
|
||||
stdout: 'ignore',
|
||||
stderr: 'ignore'
|
||||
});
|
||||
this.size = teamSize;
|
||||
this.startedAt = Date.now();
|
||||
return true;
|
||||
}
|
||||
|
||||
async select(roomName: string): Promise<boolean> {
|
||||
if (!this.process || this.process.exitCode != null || Date.now() - this.startedAt < 10_000)
|
||||
return false;
|
||||
const capture = Bun.spawn(
|
||||
[
|
||||
'ffmpeg',
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-f',
|
||||
'x11grab',
|
||||
'-video_size',
|
||||
`${this.width}x200`,
|
||||
'-i',
|
||||
`${this.display}+0,${this.height}`,
|
||||
'-frames:v',
|
||||
'1',
|
||||
'-f',
|
||||
'image2pipe',
|
||||
'-vcodec',
|
||||
'png',
|
||||
'pipe:1'
|
||||
],
|
||||
{ stdout: 'pipe', stderr: 'ignore' }
|
||||
);
|
||||
const png = new Uint8Array(await new Response(capture.stdout).arrayBuffer());
|
||||
if ((await capture.exited) !== 0 || !png.length) return false;
|
||||
const ocr = Bun.spawn(['tesseract', 'stdin', 'stdout', 'tsv'], {
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'ignore'
|
||||
});
|
||||
ocr.stdin.write(png);
|
||||
ocr.stdin.end();
|
||||
const result = await new Response(ocr.stdout).text();
|
||||
if ((await ocr.exited) !== 0) return false;
|
||||
const point = findRoom(result, roomName);
|
||||
if (!point) return false;
|
||||
return (
|
||||
(await Bun.spawn(
|
||||
['xdotool', 'mousemove', String(point.x), String(this.height + point.y), 'click', '1'],
|
||||
{ env: { ...process.env, DISPLAY: this.display }, stdout: 'ignore', stderr: 'ignore' }
|
||||
).exited) === 0
|
||||
);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.process?.kill();
|
||||
}
|
||||
}
|
||||
31
streamer/tourney/Dockerfile
Normal file
31
streamer/tourney/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
||||
# Build from the repository root:
|
||||
# docker build -f streamer/tourney/Dockerfile -t vash-tourney streamer
|
||||
FROM oven/bun:1 AS build
|
||||
WORKDIR /src
|
||||
COPY tv/main.ts tv/main.ts
|
||||
COPY agent.ts obs.ts tosu.ts tourney.ts ./
|
||||
RUN bun build tv/main.ts --compile --minify --outfile /vash-tv \
|
||||
&& bun build agent.ts --compile --minify --outfile /vash-agent
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN sed -i 's/Components: main/Components: main contrib/' /etc/apt/sources.list.d/debian.sources \
|
||||
&& dpkg --add-architecture i386 \
|
||||
&& apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
wine wine32:i386 wine64 xvfb ffmpeg chromium tini ca-certificates \
|
||||
fonts-noto-core fonts-noto-cjk fonts-noto-color-emoji xdotool tesseract-ocr \
|
||||
pulseaudio pulseaudio-utils x11vnc novnc websockify libfuse2 \
|
||||
winetricks cabextract wget unzip \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --create-home --uid 1000 tv
|
||||
COPY --from=build /vash-tv /usr/local/bin/vash-tv
|
||||
COPY --from=build /vash-agent /usr/local/bin/vash-agent
|
||||
COPY tourney/start.sh /usr/local/bin/vash-tourney-start
|
||||
RUN chmod +x /usr/local/bin/vash-tourney-start
|
||||
USER tv
|
||||
ENV WINEPREFIX=/data/wine WINEARCH=win32 GAMEPLAY_DISPLAY=:98 GAMEPLAY_WIDTH=1280 GAMEPLAY_HEIGHT=720 \
|
||||
OSU_EXE=/data/osu/osu!.exe TOSU_BIN=/data/tosu/tosu PULSE_SOURCE=vash.monitor
|
||||
WORKDIR /home/tv
|
||||
VOLUME /data
|
||||
EXPOSE 6080
|
||||
ENTRYPOINT ["tini", "-g", "--", "vash-tourney-start"]
|
||||
68
streamer/tourney/start.sh
Normal file
68
streamer/tourney/start.sh
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
: "${GAMEPLAY_DISPLAY:=:98}"
|
||||
: "${GAMEPLAY_WIDTH:=1280}"
|
||||
: "${GAMEPLAY_HEIGHT:=720}"
|
||||
: "${OSU_EXE:=/data/osu/osu!.exe}"
|
||||
: "${TOSU_BIN:=/data/tosu/tosu}"
|
||||
|
||||
if [ ! -f "$OSU_EXE" ]; then
|
||||
echo "Missing $OSU_EXE. Mount a dedicated osu! stable installation at /data/osu." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Xvfb "$GAMEPLAY_DISPLAY" -screen 0 "${GAMEPLAY_WIDTH}x$((GAMEPLAY_HEIGHT + 220))x24" -nolisten tcp &
|
||||
export DISPLAY="$GAMEPLAY_DISPLAY"
|
||||
sleep 2
|
||||
|
||||
if [ -n "${VNC_PASSWORD:-}" ]; then
|
||||
x11vnc -storepasswd "$VNC_PASSWORD" /tmp/vash-vnc.pass >/dev/null
|
||||
x11vnc -display "$GAMEPLAY_DISPLAY" -rfbauth /tmp/vash-vnc.pass -rfbport 5900 -forever -shared -quiet &
|
||||
websockify --web=/usr/share/novnc 0.0.0.0:6080 127.0.0.1:5900 &
|
||||
fi
|
||||
|
||||
pulseaudio --start --exit-idle-time=-1
|
||||
pactl load-module module-null-sink sink_name=vash >/dev/null
|
||||
export PULSE_SINK=vash
|
||||
|
||||
# On first use, run osu! normally in the VNC screen, sign in with Remember
|
||||
# Username/Password, then restart without OSU_SETUP. The agent writes
|
||||
# tournament.cfg only after this setup run has ended.
|
||||
if [ "${OSU_SETUP:-0}" = 1 ]; then
|
||||
if [ -f "$(dirname "$OSU_EXE")/tournament.cfg" ]; then
|
||||
echo "OSU_SETUP needs a fresh installation without tournament.cfg." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "${OSU_DOTNET:-0}" = 1 ]; then
|
||||
winetricks -q dotnet48
|
||||
fi
|
||||
cd "$(dirname "$OSU_EXE")"
|
||||
exec wine "$OSU_EXE"
|
||||
fi
|
||||
|
||||
if [ ! -x "$TOSU_BIN" ]; then
|
||||
echo "Missing executable $TOSU_BIN. Mount the tosu Linux build at /data/tosu/tosu." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${VASH_SERVER:-}" ] || [ -z "${VASH_CHANNEL:-}" ] || [ -z "${VASH_TOKEN:-}" ]; then
|
||||
echo "Set VASH_SERVER, VASH_CHANNEL and VASH_TOKEN." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export TOSU_OSU_PATH="$(dirname "$OSU_EXE")"
|
||||
(
|
||||
while :; do
|
||||
"$TOSU_BIN" || true
|
||||
echo "tosu stopped; restarting in 3 seconds" >&2
|
||||
sleep 3
|
||||
done
|
||||
) &
|
||||
(
|
||||
while :; do
|
||||
AUTO_JOIN=1 vash-agent --server "$VASH_SERVER" --channel "$VASH_CHANNEL" --token "$VASH_TOKEN" --osu-exe "$OSU_EXE" --auto-join || true
|
||||
echo "Vash agent stopped; restarting in 3 seconds" >&2
|
||||
sleep 3
|
||||
done
|
||||
) &
|
||||
exec vash-tv
|
||||
12
streamer/tsconfig.json
Normal file
12
streamer/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["*.ts", "tv/*.ts"]
|
||||
}
|
||||
16
streamer/tv/Dockerfile
Normal file
16
streamer/tv/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
# Vash TV: streams a Vash broadcast channel with no PC. See streamer/README.md.
|
||||
# Build from the repository root: docker build -f streamer/tv/Dockerfile -t vash-tv streamer/tv
|
||||
FROM oven/bun:1 AS build
|
||||
WORKDIR /src
|
||||
COPY main.ts .
|
||||
RUN bun build main.ts --compile --minify --outfile /vash-tv
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends chromium xvfb ffmpeg tini ca-certificates fonts-noto-core fonts-noto-cjk fonts-noto-color-emoji \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --create-home tv
|
||||
COPY --from=build /vash-tv /usr/local/bin/vash-tv
|
||||
USER tv
|
||||
WORKDIR /home/tv
|
||||
ENTRYPOINT ["tini", "--", "vash-tv"]
|
||||
703
streamer/tv/main.ts
Normal file
703
streamer/tv/main.ts
Normal file
@@ -0,0 +1,703 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* vash-tv: streams a Vash broadcast channel with no PC.
|
||||
*
|
||||
* A virtual screen shows the channel's overlay in Chromium. One ffmpeg encodes
|
||||
* it; its output is copied to one small ffmpeg per destination (Twitch,
|
||||
* YouTube, …), so a destination that fails reconnects on its own without
|
||||
* touching the others. The same stream is cut into 2-second HLS segments and
|
||||
* uploaded to storage: that is the live stream on the Vash site and the
|
||||
* recording afterwards.
|
||||
*
|
||||
* It follows the channel's "Go live" switch on the site:
|
||||
* VASH_SERVER=https://esports.vash.software VASH_CHANNEL=<id> VASH_TOKEN=<agent token>
|
||||
* Or, without Vash managing it, one overlay to one destination:
|
||||
* OVERLAY_URL=https://…/overlay RTMP_URL=rtmp://…/<key>
|
||||
* Or relay OBS: INGEST_KEY=<secret> makes it listen on rtmp://<host>:1935/live
|
||||
* (INGEST_PORT to change) and record and restream what OBS sends, unchanged.
|
||||
* Optional: MUSIC_URL (looped audio), OVERLAY_ORIGIN (load the overlay from another
|
||||
* address, e.g. http://127.0.0.1:3102 next to Vash), DURATION (seconds, for tests).
|
||||
*/
|
||||
import { mkdir, readdir, readFile, rm } from 'node:fs/promises';
|
||||
import type { Subprocess } from 'bun';
|
||||
|
||||
const env = process.env;
|
||||
const server = env.VASH_SERVER?.replace(/\/$/, '');
|
||||
const channelId = env.VASH_CHANNEL;
|
||||
const token = env.VASH_TOKEN;
|
||||
const managed = !!(server && channelId && token);
|
||||
const HLS = '/tmp/vash-hls';
|
||||
/** A second virtual display with osu!tourney's streamable area at its top left. */
|
||||
const gameplayDisplay = env.GAMEPLAY_DISPLAY;
|
||||
const gameplayWidth = Number(env.GAMEPLAY_WIDTH ?? 1280);
|
||||
const gameplayHeight = Number(env.GAMEPLAY_HEIGHT ?? 720);
|
||||
const say = (...parts: unknown[]) => console.log(new Date().toISOString().slice(11, 19), ...parts);
|
||||
|
||||
interface Destination {
|
||||
id: string;
|
||||
platform: string;
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface Config {
|
||||
live: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
bitrateKbps: number;
|
||||
overlayUrl: string;
|
||||
record: boolean;
|
||||
destinations: Destination[];
|
||||
}
|
||||
|
||||
if (!managed && !((env.OVERLAY_URL || env.INGEST_KEY) && env.RTMP_URL)) {
|
||||
console.error('Set VASH_SERVER, VASH_CHANNEL and VASH_TOKEN, or OVERLAY_URL and RTMP_URL.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Vash API ────────────────────────────────────────────────────────────
|
||||
|
||||
async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${server}/api/broadcast/${channelId}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
...init.headers
|
||||
},
|
||||
signal: AbortSignal.timeout(15_000)
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`${path}: ${response.status} ${await response.text().catch(() => '')}`.slice(0, 300)
|
||||
);
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function loadConfig(): Promise<Config> {
|
||||
if (!managed) {
|
||||
return {
|
||||
live: true,
|
||||
width: Number(env.WIDTH ?? 1280),
|
||||
height: Number(env.HEIGHT ?? 720),
|
||||
fps: Number(env.FPS ?? 30),
|
||||
bitrateKbps: Number((env.VIDEO_BITRATE ?? '3000k').replace(/k$/, '')),
|
||||
overlayUrl: env.OVERLAY_URL ?? '',
|
||||
record: false,
|
||||
destinations: [{ id: 'rtmp', platform: 'custom', label: 'RTMP', url: env.RTMP_URL! }]
|
||||
};
|
||||
}
|
||||
const config = await api<Config>('/stream');
|
||||
// A container next to Vash (or a dev site behind a login) can load the overlay internally.
|
||||
if (env.OVERLAY_ORIGIN)
|
||||
config.overlayUrl = config.overlayUrl.replace(
|
||||
/^https?:\/\/[^/]+/,
|
||||
env.OVERLAY_ORIGIN.replace(/\/$/, '')
|
||||
);
|
||||
return config;
|
||||
}
|
||||
|
||||
// ── Screen ──────────────────────────────────────────────────────────────
|
||||
|
||||
let screen: {
|
||||
width: number;
|
||||
height: number;
|
||||
url: string;
|
||||
xvfb: Subprocess;
|
||||
browser: Subprocess | null;
|
||||
} | null = null;
|
||||
|
||||
function spawnBrowser(url: string, width: number, height: number) {
|
||||
const background = gameplayDisplay ? 'key' : 'solid';
|
||||
const page = url.includes('?') ? `${url}&bg=${background}` : `${url}?bg=${background}`;
|
||||
return Bun.spawn(
|
||||
[
|
||||
'chromium',
|
||||
'--no-sandbox',
|
||||
'--kiosk',
|
||||
'--incognito',
|
||||
'--no-first-run',
|
||||
'--noerrdialogs',
|
||||
'--disable-infobars',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
'--hide-scrollbars',
|
||||
'--force-device-scale-factor=1',
|
||||
'--autoplay-policy=no-user-gesture-required',
|
||||
'--window-position=0,0',
|
||||
`--window-size=${width},${height}`,
|
||||
page
|
||||
],
|
||||
{ env: { ...env, DISPLAY: ':99' }, stdout: 'ignore', stderr: 'ignore' }
|
||||
);
|
||||
}
|
||||
|
||||
async function showScreen(config: Config) {
|
||||
if (
|
||||
screen &&
|
||||
screen.width === config.width &&
|
||||
screen.height === config.height &&
|
||||
screen.url === config.overlayUrl
|
||||
)
|
||||
return;
|
||||
stopScreen();
|
||||
const xvfb = Bun.spawn(
|
||||
['Xvfb', ':99', '-screen', '0', `${config.width}x${config.height}x24`, '-nolisten', 'tcp'],
|
||||
{ stdout: 'ignore', stderr: 'ignore' }
|
||||
);
|
||||
await Bun.sleep(800);
|
||||
screen = {
|
||||
width: config.width,
|
||||
height: config.height,
|
||||
url: config.overlayUrl,
|
||||
xvfb,
|
||||
browser: null
|
||||
};
|
||||
const keepBrowser = async () => {
|
||||
const current = screen;
|
||||
if (!current || current.xvfb !== xvfb) return;
|
||||
current.browser = spawnBrowser(config.overlayUrl, config.width, config.height);
|
||||
await current.browser.exited;
|
||||
// Chromium should never exit; bring it back if it does.
|
||||
if (screen === current) setTimeout(keepBrowser, 2000);
|
||||
};
|
||||
void keepBrowser();
|
||||
await Bun.sleep(5000);
|
||||
say(`Screen ${config.width}x${config.height} showing ${config.overlayUrl}`);
|
||||
}
|
||||
|
||||
function stopScreen() {
|
||||
if (!screen) return;
|
||||
const old = screen;
|
||||
screen = null;
|
||||
old.browser?.kill();
|
||||
old.xvfb.kill();
|
||||
}
|
||||
|
||||
// ── Encoder and outputs ────────────────────────────────────────────────
|
||||
|
||||
/** One consumer of the encoded stream: a destination, or the recorder. */
|
||||
class Output {
|
||||
state: 'starting' | 'live' | 'reconnecting' | 'error' = 'starting';
|
||||
error: string | null = null;
|
||||
private process: Subprocess<'pipe', 'ignore', 'pipe'> | null = null;
|
||||
private since = 0;
|
||||
private queue: Uint8Array[] = [];
|
||||
private queued = 0;
|
||||
private writing = false;
|
||||
private attempts = 0;
|
||||
private stopped = false;
|
||||
|
||||
constructor(
|
||||
readonly id: string,
|
||||
readonly label: string,
|
||||
private args: string[]
|
||||
) {
|
||||
this.spawn();
|
||||
}
|
||||
|
||||
private spawn() {
|
||||
if (this.stopped) return;
|
||||
const process = Bun.spawn(
|
||||
[
|
||||
'ffmpeg',
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-f',
|
||||
'mpegts',
|
||||
'-i',
|
||||
'pipe:0',
|
||||
...this.args
|
||||
],
|
||||
{
|
||||
stdin: 'pipe',
|
||||
stdout: 'ignore',
|
||||
stderr: 'pipe'
|
||||
}
|
||||
);
|
||||
this.process = process;
|
||||
this.since = Date.now();
|
||||
this.queue = [];
|
||||
this.queued = 0;
|
||||
void (async () => {
|
||||
let last = '';
|
||||
for await (const chunk of process.stderr)
|
||||
last = new TextDecoder().decode(chunk).trim().split('\n').pop() || last;
|
||||
const code = await process.exited;
|
||||
if (this.stopped || this.process !== process) return;
|
||||
this.error = last || `ffmpeg exited with ${code}`;
|
||||
this.state = this.attempts > 3 ? 'error' : 'reconnecting';
|
||||
const wait = Math.min(30_000, 2000 * 2 ** this.attempts++);
|
||||
say(`${this.label}: ${this.error}; retrying in ${Math.round(wait / 1000)}s`);
|
||||
setTimeout(() => this.spawn(), wait);
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues bytes in order. Dropping any would corrupt the MPEG-TS timestamps,
|
||||
* so a destination that falls far behind is restarted instead.
|
||||
*/
|
||||
write(chunk: Uint8Array) {
|
||||
const process = this.process;
|
||||
if (!process || this.stopped) return;
|
||||
if (this.state !== 'live' && Date.now() - this.since > 5000 && process.exitCode == null) {
|
||||
this.state = 'live';
|
||||
this.error = null;
|
||||
this.attempts = 0;
|
||||
}
|
||||
this.queue.push(chunk);
|
||||
this.queued += chunk.byteLength;
|
||||
if (this.queued > 32 * 1024 * 1024) {
|
||||
this.error = 'Destination too slow; restarting';
|
||||
this.queue = [];
|
||||
this.queued = 0;
|
||||
process.kill();
|
||||
return;
|
||||
}
|
||||
void this.drain(process);
|
||||
}
|
||||
|
||||
private async drain(process: Subprocess<'pipe', 'ignore', 'pipe'>) {
|
||||
if (this.writing) return;
|
||||
this.writing = true;
|
||||
try {
|
||||
while (this.queue.length && this.process === process) {
|
||||
const chunk = this.queue.shift()!;
|
||||
this.queued -= chunk.byteLength;
|
||||
process.stdin.write(chunk);
|
||||
const flushed = process.stdin.flush();
|
||||
if (flushed instanceof Promise) await flushed;
|
||||
}
|
||||
} catch {
|
||||
// The process is exiting; its watcher restarts it.
|
||||
} finally {
|
||||
this.writing = false;
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.stopped = true;
|
||||
try {
|
||||
this.process?.stdin.end();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
const process = this.process;
|
||||
setTimeout(() => process?.kill(), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
let encoder: Subprocess<'ignore', 'pipe', 'pipe'> | null = null;
|
||||
const outputs = new Map<string, Output>();
|
||||
|
||||
/** OBS (or anything) streams here instead of the built-in screen; nothing is re-encoded. */
|
||||
const ingestKey = env.INGEST_KEY;
|
||||
const ingestPort = Number(env.INGEST_PORT ?? 1935);
|
||||
|
||||
function startIngest() {
|
||||
const process = Bun.spawn(
|
||||
[
|
||||
'ffmpeg',
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-listen',
|
||||
'1',
|
||||
'-i',
|
||||
`rtmp://0.0.0.0:${ingestPort}/live/${ingestKey}`,
|
||||
'-c',
|
||||
'copy',
|
||||
'-f',
|
||||
'mpegts',
|
||||
'pipe:1'
|
||||
],
|
||||
{ stdout: 'pipe', stderr: 'pipe' }
|
||||
);
|
||||
encoder = process;
|
||||
say(`Waiting for OBS on rtmp://<this host>:${ingestPort}/live with the ingest key`);
|
||||
void (async () => {
|
||||
for await (const chunk of process.stdout)
|
||||
for (const output of outputs.values()) output.write(chunk);
|
||||
})();
|
||||
void (async () => {
|
||||
await process.exited;
|
||||
// OBS stopped or dropped; listen again.
|
||||
if (encoder === process) encoder = null;
|
||||
})();
|
||||
}
|
||||
|
||||
function startEncoder(config: Config) {
|
||||
if (ingestKey) return startIngest();
|
||||
const audio = env.PULSE_SOURCE
|
||||
? ['-f', 'pulse', '-i', env.PULSE_SOURCE]
|
||||
: env.MUSIC_URL
|
||||
? ['-stream_loop', '-1', '-i', env.MUSIC_URL]
|
||||
: ['-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100'];
|
||||
const kbps = config.bitrateKbps;
|
||||
const gameplay = gameplayDisplay
|
||||
? [
|
||||
'-f',
|
||||
'x11grab',
|
||||
'-draw_mouse',
|
||||
'0',
|
||||
'-framerate',
|
||||
String(config.fps),
|
||||
'-video_size',
|
||||
`${gameplayWidth}x${gameplayHeight}`,
|
||||
'-i',
|
||||
gameplayDisplay
|
||||
]
|
||||
: [];
|
||||
const video = gameplayDisplay
|
||||
? [
|
||||
'-filter_complex',
|
||||
`[1:v]scale=${config.width}:${config.height}:force_original_aspect_ratio=decrease,pad=${config.width}:${config.height}:(ow-iw)/2:(oh-ih)/2[game];[0:v]chromakey=0x00ff00:0.04:0.0[overlay];[game][overlay]overlay=0:0:shortest=1[v]`,
|
||||
'-map',
|
||||
'[v]'
|
||||
]
|
||||
: ['-map', '0:v'];
|
||||
const process = Bun.spawn(
|
||||
[
|
||||
'ffmpeg',
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-f',
|
||||
'x11grab',
|
||||
'-draw_mouse',
|
||||
'0',
|
||||
'-framerate',
|
||||
String(config.fps),
|
||||
'-video_size',
|
||||
`${config.width}x${config.height}`,
|
||||
'-i',
|
||||
':99',
|
||||
...gameplay,
|
||||
...audio,
|
||||
...video,
|
||||
'-map',
|
||||
gameplayDisplay ? '2:a' : '1:a',
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-preset',
|
||||
env.PRESET ?? 'veryfast',
|
||||
'-b:v',
|
||||
`${kbps}k`,
|
||||
'-maxrate',
|
||||
`${kbps}k`,
|
||||
'-bufsize',
|
||||
`${kbps * 2}k`,
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
'-g',
|
||||
String(config.fps * 2),
|
||||
'-keyint_min',
|
||||
String(config.fps * 2),
|
||||
'-sc_threshold',
|
||||
'0',
|
||||
'-c:a',
|
||||
'aac',
|
||||
'-b:a',
|
||||
'160k',
|
||||
'-ar',
|
||||
'44100',
|
||||
'-f',
|
||||
'mpegts',
|
||||
'pipe:1'
|
||||
],
|
||||
{ stdout: 'pipe', stderr: 'pipe', env: { ...env, DISPLAY: ':99' } }
|
||||
);
|
||||
encoder = process;
|
||||
void (async () => {
|
||||
for await (const chunk of process.stdout)
|
||||
for (const output of outputs.values()) output.write(chunk);
|
||||
})();
|
||||
void (async () => {
|
||||
let last = '';
|
||||
for await (const chunk of process.stderr) last = new TextDecoder().decode(chunk).trim() || last;
|
||||
const code = await process.exited;
|
||||
if (encoder === process) {
|
||||
say(`Encoder stopped (${code}): ${last}`);
|
||||
encoder = null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function syncDestinations(config: Config) {
|
||||
const wanted = new Map(config.destinations.map((destination) => [destination.id, destination]));
|
||||
for (const [id, output] of outputs) {
|
||||
if (id === 'recording') continue;
|
||||
const destination = wanted.get(id);
|
||||
if (!destination || (output as unknown as { url?: string }).url !== destination.url) {
|
||||
output.stop();
|
||||
outputs.delete(id);
|
||||
if (destination) say(`${output.label}: settings changed, reconnecting`);
|
||||
else say(`${output.label}: removed`);
|
||||
}
|
||||
}
|
||||
for (const destination of config.destinations) {
|
||||
if (outputs.has(destination.id)) continue;
|
||||
const output = new Output(destination.id, destination.label, [
|
||||
'-c',
|
||||
'copy',
|
||||
'-f',
|
||||
'flv',
|
||||
destination.url
|
||||
]);
|
||||
(output as unknown as { url: string }).url = destination.url;
|
||||
outputs.set(destination.id, output);
|
||||
say(`${destination.label}: connecting`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recording (HLS to storage) ─────────────────────────────────────────
|
||||
|
||||
let session: { id: string; storagePrefix: string | null; startedAt: number } | null = null;
|
||||
const vod: { name: string; duration: number }[] = [];
|
||||
const uploaded = new Set<string>();
|
||||
let lastVodUpload = 0;
|
||||
let posterDone = false;
|
||||
|
||||
async function upload(
|
||||
files: Record<string, { body: Uint8Array | string; type: string; cache?: string }>
|
||||
) {
|
||||
if (!session?.storagePrefix) return;
|
||||
const names = Object.keys(files);
|
||||
const { urls } = await api<{ urls: Record<string, string> }>('/uploads', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sessionId: session.id, files: names })
|
||||
});
|
||||
await Promise.all(
|
||||
names.map(async (name) => {
|
||||
const file = files[name];
|
||||
const response = await fetch(urls[name], {
|
||||
method: 'PUT',
|
||||
body: file.body,
|
||||
headers: {
|
||||
'content-type': file.type,
|
||||
...(file.cache ? { 'cache-control': file.cache } : {})
|
||||
}
|
||||
});
|
||||
if (!response.ok) throw new Error(`upload ${name}: ${response.status}`);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function vodPlaylist(ended: boolean) {
|
||||
const target = Math.ceil(Math.max(2, ...vod.map((each) => each.duration)));
|
||||
return [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-VERSION:3',
|
||||
`#EXT-X-TARGETDURATION:${target}`,
|
||||
'#EXT-X-MEDIA-SEQUENCE:0',
|
||||
`#EXT-X-PLAYLIST-TYPE:${ended ? 'VOD' : 'EVENT'}`,
|
||||
...vod.flatMap((each) => [`#EXTINF:${each.duration.toFixed(3)},`, `live/${each.name}`]),
|
||||
...(ended ? ['#EXT-X-ENDLIST'] : [])
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** Uploads new segments, then the live playlist, and now and then the growing VOD playlist. */
|
||||
async function pumpRecording(final = false) {
|
||||
if (!session?.storagePrefix) return;
|
||||
let playlist: string;
|
||||
try {
|
||||
playlist = await readFile(`${HLS}/index.m3u8`, 'utf8');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const entries: { name: string; duration: number }[] = [];
|
||||
const lines = playlist.split('\n');
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const match = /^#EXTINF:([\d.]+)/.exec(lines[index]);
|
||||
if (match && lines[index + 1])
|
||||
entries.push({ name: lines[index + 1].trim(), duration: Number(match[1]) });
|
||||
}
|
||||
const fresh = entries.filter((entry) => !uploaded.has(entry.name));
|
||||
if (fresh.length) {
|
||||
const files: Record<string, { body: Uint8Array; type: string }> = {};
|
||||
for (const entry of fresh)
|
||||
files[`live/${entry.name}`] = {
|
||||
body: new Uint8Array(await readFile(`${HLS}/${entry.name}`)),
|
||||
type: 'video/mp2t'
|
||||
};
|
||||
await upload(files);
|
||||
for (const entry of fresh) {
|
||||
uploaded.add(entry.name);
|
||||
vod.push(entry);
|
||||
}
|
||||
await upload({
|
||||
'live/index.m3u8': {
|
||||
body: playlist,
|
||||
type: 'application/vnd.apple.mpegurl',
|
||||
cache: 'no-cache, max-age=1'
|
||||
}
|
||||
});
|
||||
}
|
||||
if (final || Date.now() - lastVodUpload > 30_000) {
|
||||
lastVodUpload = Date.now();
|
||||
await upload({
|
||||
'vod.m3u8': {
|
||||
body: vodPlaylist(final),
|
||||
type: 'application/vnd.apple.mpegurl',
|
||||
cache: final ? 'max-age=3600' : 'no-cache'
|
||||
}
|
||||
});
|
||||
}
|
||||
// Local segments that left the live playlist are safely stored; free the disk.
|
||||
const live = new Set(entries.map((entry) => entry.name));
|
||||
for (const name of await readdir(HLS))
|
||||
if (name.endsWith('.ts') && uploaded.has(name) && !live.has(name))
|
||||
await rm(`${HLS}/${name}`, { force: true });
|
||||
if (!posterDone && session && Date.now() - session.startedAt > 15_000 && entries.length) {
|
||||
posterDone = true;
|
||||
const latest = entries[entries.length - 1].name;
|
||||
const grab = Bun.spawn([
|
||||
'ffmpeg',
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-y',
|
||||
'-i',
|
||||
`${HLS}/${latest}`,
|
||||
'-frames:v',
|
||||
'1',
|
||||
'-vf',
|
||||
'scale=640:-2',
|
||||
`${HLS}/poster.jpg`
|
||||
]);
|
||||
if ((await grab.exited) === 0)
|
||||
await upload({
|
||||
'poster.jpg': {
|
||||
body: new Uint8Array(await readFile(`${HLS}/poster.jpg`)),
|
||||
type: 'image/jpeg'
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ── On and off air ──────────────────────────────────────────────────────
|
||||
|
||||
let onAir = false;
|
||||
|
||||
async function goLive(config: Config) {
|
||||
if (!ingestKey) await showScreen(config);
|
||||
if (managed) {
|
||||
const started = await api<{ id: string; storagePrefix: string | null }>('/stream', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ event: 'start' })
|
||||
});
|
||||
session = { ...started, startedAt: Date.now() };
|
||||
vod.length = 0;
|
||||
uploaded.clear();
|
||||
posterDone = false;
|
||||
}
|
||||
if (config.record && session?.storagePrefix) {
|
||||
await rm(HLS, { recursive: true, force: true });
|
||||
await mkdir(HLS, { recursive: true });
|
||||
outputs.set(
|
||||
'recording',
|
||||
new Output('recording', 'Recording', [
|
||||
'-c',
|
||||
'copy',
|
||||
'-f',
|
||||
'hls',
|
||||
'-hls_time',
|
||||
'2',
|
||||
'-hls_list_size',
|
||||
'8',
|
||||
'-hls_segment_filename',
|
||||
`${HLS}/seg_%05d.ts`,
|
||||
`${HLS}/index.m3u8`
|
||||
])
|
||||
);
|
||||
}
|
||||
syncDestinations(config);
|
||||
startEncoder(config);
|
||||
onAir = true;
|
||||
say(`On air: ${config.destinations.map((each) => each.label).join(', ') || 'recording only'}`);
|
||||
}
|
||||
|
||||
async function goOffAir() {
|
||||
onAir = false;
|
||||
encoder?.kill();
|
||||
encoder = null;
|
||||
for (const output of outputs.values()) output.stop();
|
||||
outputs.clear();
|
||||
await Bun.sleep(2500);
|
||||
await pumpRecording(true).catch((err) => say('Final upload failed:', (err as Error).message));
|
||||
if (managed && session) {
|
||||
await api('/stream', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
event: 'end',
|
||||
sessionId: session.id,
|
||||
durationSec: Math.round((Date.now() - session.startedAt) / 1000)
|
||||
})
|
||||
}).catch(() => {});
|
||||
}
|
||||
session = null;
|
||||
say('Off air');
|
||||
}
|
||||
|
||||
async function report() {
|
||||
if (!managed || !session) return;
|
||||
await api('/stream', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
event: 'status',
|
||||
sessionId: session.id,
|
||||
durationSec: Math.round((Date.now() - session.startedAt) / 1000),
|
||||
destinations: [...outputs.values()]
|
||||
.filter((output) => output.id !== 'recording')
|
||||
.map((output) => ({
|
||||
id: output.id,
|
||||
label: output.label,
|
||||
state: output.state,
|
||||
error: output.error
|
||||
}))
|
||||
})
|
||||
}).catch((err) => say('Status report failed:', (err as Error).message));
|
||||
}
|
||||
|
||||
let stopping = false;
|
||||
async function shutdown() {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
if (onAir) await goOffAir();
|
||||
stopScreen();
|
||||
process.exit(0);
|
||||
}
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
say(managed ? `Following channel ${channelId} on ${server}` : `Streaming ${env.OVERLAY_URL}`);
|
||||
if (env.DURATION) setTimeout(shutdown, Number(env.DURATION) * 1000);
|
||||
|
||||
let lastReport = 0;
|
||||
let lastConfig = 0;
|
||||
while (!stopping) {
|
||||
if (Date.now() - lastConfig >= 5000) {
|
||||
lastConfig = Date.now();
|
||||
try {
|
||||
const config = await loadConfig();
|
||||
if (config.live && !onAir) await goLive(config);
|
||||
else if (!config.live && onAir) await goOffAir();
|
||||
else if (onAir) {
|
||||
syncDestinations(config);
|
||||
if (!encoder) startEncoder(config);
|
||||
}
|
||||
} catch (err) {
|
||||
say('Vash unreachable:', (err as Error).message);
|
||||
}
|
||||
}
|
||||
if (onAir) {
|
||||
await pumpRecording().catch((err) => say('Recording upload failed:', (err as Error).message));
|
||||
if (Date.now() - lastReport > 10_000) {
|
||||
lastReport = Date.now();
|
||||
await report();
|
||||
}
|
||||
}
|
||||
await Bun.sleep(1000);
|
||||
}
|
||||
Reference in New Issue
Block a user