webapp: introduce Laravel app under webapp/ for scene overlays

Replaces the per-scene HTML directories (landing/, loading/, game/,
desktop/, goodbye/, music-box/, music/) with a single Laravel app
serving every overlay over HTTP. Supervisord runs `php artisan serve`
on 127.0.0.1:1118 and the OBS scene JSON now references HTTP routes
instead of file:// URLs.

Highlights:
 - public/css/hud.css consolidates the duplicated HUD chrome,
   scanlines/vignette/flicker, terminal styling, and pulse keyframes
   that were copy-pasted across all seven scenes.
 - Blade partials own hud-strip, crt-overlays, obs-ws-scripts,
   camera-frame, screen-frame; the seven scenes extend a shared
   overlay layout.
 - Artisan commands (`rig:setup`, `rig:telemetry`, `rig:loading`,
   `rig:playlist`, `rig:cmd`) replace the shell scripts that wrote
   per-rig JSON snapshots. TwitchHelix + HardwareSnapshot services
   handle the work the bash + Python helpers used to.
 - ObsWsClient + MusicCommandController kill the 250 ms cmd.js poll
   in the music daemon: POST /cmd/{skip|prev|pause|resume} opens a
   short-lived Pawl WS, authenticates, and broadcasts mpd:cmd.
 - AudioController streams files from the configured music dirs so
   CEF can load tracks under the HTTP origin (Chromium blocks
   HTTP-origin pages from loading file:// media).
 - DataController serves /data/playlist.js (with ETag mtime cache)
   and /cover.jpg (no-store) so the existing overlays' window.__PLAYLIST
   and cover.jpg cache-bust pattern keeps working.

scripts/obs-webapp.supervisord.conf is the supervisord unit; install
to /etc/supervisor.d/obs-webapp.conf.
scripts/rewrite-scene-urls.py is a one-shot tool that rewrites
basic/scenes/Default_Stream_HUD.json from file:// to HTTP URLs.
basic/scenes/Default_Stream_HUD.json.pre-webapp is the rollback
artifact (made with OBS closed; full pre-migration state).

The seven old scene directories, vendor/, and the bash scripts are
still on disk pending visual verification; the next commit will
prune them.
This commit is contained in:
Jakub Zych
2026-05-21 12:47:10 +02:00
parent c8f9c11c93
commit 059f069ef4
85 changed files with 19180 additions and 49 deletions

View File

@@ -0,0 +1,186 @@
<?php
namespace App\Services;
/**
* Captures the static device + OBS config snapshot shown on the landing
* overlay's telemetry rotator. Direct port of scripts/telemetry.sh.
*/
class HardwareSnapshot
{
public function collect(): array
{
return [
'collectedAt' => gmdate('Y-m-d\TH:i:s\Z'),
'rig' => $this->rigName(),
'cpu' => $this->cpu(),
'mem' => $this->mem(),
'host' => ['kernel' => $this->kernel()],
'gpu' => $this->gpu(),
'obs' => $this->obs(),
];
}
private function rigName(): string
{
$name = (string) (config('rig.rig_name') ?: gethostname());
return ucfirst(strtolower($name));
}
private function cpu(): array
{
$model = null;
if (is_readable('/proc/cpuinfo')) {
foreach (file('/proc/cpuinfo') as $line) {
if (preg_match('/^model name\s*:\s*(.+)$/', $line, $m)) {
$model = trim($m[1]);
break;
}
}
}
$threads = (int) (shell_exec('nproc 2>/dev/null') ?: 0);
return ['model' => $model, 'threads' => $threads ?: null];
}
private function mem(): array
{
$kb = 0;
if (is_readable('/proc/meminfo')) {
foreach (file('/proc/meminfo') as $line) {
if (preg_match('/^MemTotal:\s+(\d+)\s*kB/', $line, $m)) {
$kb = (int) $m[1];
break;
}
}
}
$gb = $kb > 0 ? round($kb / 1024 / 1024, 1) : null;
return ['totalGB' => $gb];
}
private function kernel(): ?string
{
$k = trim((string) shell_exec('uname -r 2>/dev/null'));
return $k === '' ? null : $k;
}
private function gpu(): array
{
$name = null;
$vram = null;
if (trim((string) shell_exec('command -v nvidia-smi 2>/dev/null')) !== '') {
$row = trim((string) shell_exec(
'nvidia-smi --query-gpu=name,memory.total --format=csv,noheader,nounits 2>/dev/null | head -n1'
));
if ($row !== '') {
[$n, $v] = array_pad(array_map('trim', explode(',', $row, 2)), 2, null);
$name = $n !== '' ? $n : null;
$vram = is_numeric($v) ? (int) $v : null;
}
}
return ['name' => $name, 'vramTotalMB' => $vram];
}
private function obs(): ?array
{
$obsDir = rtrim((string) config('rig.obs_studio_dir'), '/');
$userIni = $obsDir . '/user.ini';
if (!is_file($userIni)) return null;
$user = $this->parseIni($userIni);
$profile = $user['Basic']['Profile'] ?? 'Untitled';
$renderer = $user['Video']['Renderer'] ?? null;
$profileDir = "{$obsDir}/basic/profiles/{$profile}";
$basicIni = "{$profileDir}/basic.ini";
if (!is_file($basicIni)) return null;
$b = $this->parseIni($basicIni);
$mode = $b['Output']['Mode'] ?? null;
$baseW = isset($b['Video']['BaseCX']) ? (int) $b['Video']['BaseCX'] : null;
$baseH = isset($b['Video']['BaseCY']) ? (int) $b['Video']['BaseCY'] : null;
$outW = isset($b['Video']['OutputCX']) ? (int) $b['Video']['OutputCX'] : null;
$outH = isset($b['Video']['OutputCY']) ? (int) $b['Video']['OutputCY'] : null;
$fps = isset($b['Video']['FPSInt']) ? (int) $b['Video']['FPSInt'] : null;
$encRaw = '';
$bitrate = null;
$rc = null;
$keyint = null;
$h264Profile = null;
if ($mode === 'Advanced') {
$encRaw = $b['AdvOut']['Encoder'] ?? '';
$sej = "{$profileDir}/streamEncoder.json";
if (is_file($sej)) {
$j = json_decode((string) file_get_contents($sej), true) ?: [];
$rc = $j['rate_control'] ?? null;
$bitrate = isset($j['bitrate']) ? (int) $j['bitrate'] : null;
$keyint = isset($j['keyint_sec']) ? (int) $j['keyint_sec'] : null;
$h264Profile = $j['profile'] ?? null;
}
} else {
$encRaw = $b['SimpleOutput']['StreamEncoder'] ?? '';
$bitrate = isset($b['SimpleOutput']['VBitrate']) ? (int) $b['SimpleOutput']['VBitrate'] : null;
}
return [
'profile' => $profile,
'renderer' => $renderer,
'outputMode' => $mode,
'canvas' => ['w' => $baseW, 'h' => $baseH],
'output' => ['w' => $outW, 'h' => $outH],
'fps' => $fps,
'color' => [
'format' => $b['Video']['ColorFormat'] ?? null,
'space' => $b['Video']['ColorSpace'] ?? null,
'range' => $b['Video']['ColorRange'] ?? null,
],
'stream' => [
'encoder' => $this->prettifyEncoder($encRaw),
'rateControl' => $rc,
'bitrateKbps' => $bitrate,
'keyintSec' => $keyint,
'profile' => $h264Profile,
],
'audio' => [
'sampleRateHz' => isset($b['Audio']['SampleRate']) ? (int) $b['Audio']['SampleRate'] : null,
'channels' => $b['Audio']['ChannelSetup'] ?? null,
],
];
}
private function prettifyEncoder(string $id): ?string
{
$map = [
'obs_x264' => 'x264', 'x264' => 'x264',
'jim_nvenc' => 'NVENC H.264', 'ffmpeg_nvenc' => 'NVENC H.264',
'obs_nvenc_h264_tex' => 'NVENC H.264', 'obs_nvenc_h264_soft' => 'NVENC H.264',
'obs_nvenc_hevc_tex' => 'NVENC HEVC', 'obs_nvenc_hevc_soft' => 'NVENC HEVC',
'obs_nvenc_av1_tex' => 'NVENC AV1', 'obs_nvenc_av1_soft' => 'NVENC AV1',
'obs_qsv11' => 'QSV',
];
if ($id === '') return null;
return $map[$id] ?? $id;
}
/** @return array<string, array<string, string>> */
private function parseIni(string $path): array
{
$data = [];
$section = null;
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$trim = trim($line);
if ($trim === '' || str_starts_with($trim, ';') || str_starts_with($trim, '#')) continue;
if (preg_match('/^\[(.+)\]$/', $trim, $m)) {
$section = $m[1];
$data[$section] = $data[$section] ?? [];
continue;
}
if ($section !== null && str_contains($line, '=')) {
[$k, $v] = explode('=', $line, 2);
$data[$section][trim($k)] = trim($v);
}
}
return $data;
}
}

View File

@@ -0,0 +1,158 @@
<?php
namespace App\Services;
use Exception;
use Ratchet\Client\Connector as PawlConnector;
use Ratchet\Client\WebSocket;
use Ratchet\RFC6455\Messaging\MessageInterface;
use React\EventLoop\Loop;
use Throwable;
/**
* Short-lived OBS WebSocket v5 client used to broadcast `mpd:cmd` from the
* Laravel side (POST /cmd/{type} and `php artisan rig:cmd`).
*
* Mirrors the auth + protocol opcodes implemented in public/js/obs-ws-mini.js:
* - op 0 Hello server sends auth challenge if password set
* - op 1 Identify client sends auth string + event subscriptions
* - op 2 Identified identification OK; we can now send Requests
* - op 6 Request BroadcastCustomEvent with eventData = { _type, ...payload }
* - op 7 Response request acknowledged
*
* Auth string: base64(SHA256(base64(SHA256(password + salt)) + challenge))
*
* Connect identify broadcast close, all inside a single React event-loop
* tick. Total wall time on localhost is typically 3080 ms.
*/
class ObsWsClient
{
private string $url;
private string $password;
private int $timeoutMs;
public function __construct(?string $url = null, ?string $password = null, int $timeoutMs = 5000)
{
$this->url = $url ?? (string) config('rig.obs_ws.url');
$this->password = $password ?? (string) config('rig.obs_ws.password');
$this->timeoutMs = $timeoutMs;
}
/**
* Broadcast a CustomEvent through OBS WS to every connected client.
* `_type` is added to eventData so the mini client's `onCustom(typeFilter)` works.
*/
public function broadcast(string $type, array $payload = []): void
{
$eventData = array_merge(['_type' => $type], $payload);
$this->sendRequest('BroadcastCustomEvent', ['eventData' => $eventData]);
}
public function sendRequest(string $requestType, array $requestData = []): array
{
$loop = Loop::get();
$connector = new PawlConnector($loop);
$result = null;
$error = null;
$identified = false;
$requestId = 'php-' . bin2hex(random_bytes(6));
$promise = $connector($this->url);
$promise->then(function (WebSocket $conn) use (&$result, &$error, &$identified, $requestId, $requestType, $requestData) {
$conn->on('message', function (MessageInterface $msg) use ($conn, &$result, &$error, &$identified, $requestId, $requestType, $requestData) {
$payload = json_decode((string) $msg, true);
if (!is_array($payload) || !isset($payload['op'])) return;
try {
switch ($payload['op']) {
case 0: // Hello
$auth = null;
if (isset($payload['d']['authentication'])) {
if ($this->password === '') {
$error = new Exception('OBS WS server requires password but none configured');
$conn->close();
return;
}
$auth = $this->authString(
$payload['d']['authentication']['salt'],
$payload['d']['authentication']['challenge']
);
}
$identify = [
'op' => 1,
'd' => [
'rpcVersion' => 1,
'authentication' => $auth,
'eventSubscriptions' => 0,
],
];
// null auth would serialize as JSON null; OBS WS expects the key absent when no auth.
if ($auth === null) unset($identify['d']['authentication']);
$conn->send(json_encode($identify));
break;
case 2: // Identified
$identified = true;
$conn->send(json_encode([
'op' => 6,
'd' => [
'requestType' => $requestType,
'requestId' => $requestId,
'requestData' => (object) $requestData,
],
]));
break;
case 7: // RequestResponse
if (($payload['d']['requestId'] ?? null) !== $requestId) return;
$status = $payload['d']['requestStatus'] ?? [];
if (!empty($status['result'])) {
$result = $payload['d']['responseData'] ?? [];
} else {
$error = new Exception(
'OBS WS request failed: ' . ($status['comment'] ?? 'unknown')
);
}
$conn->close();
break;
}
} catch (Throwable $e) {
$error = $e;
$conn->close();
}
});
$conn->on('close', function () {
Loop::stop();
});
$conn->on('error', function ($e) use (&$error) {
$error = $e instanceof Throwable ? $e : new Exception((string) $e);
Loop::stop();
});
}, function ($e) use (&$error) {
$error = $e instanceof Throwable ? $e : new Exception((string) $e);
Loop::stop();
});
// Hard timeout guard — Loop::stop fires no matter what.
$timer = $loop->addTimer($this->timeoutMs / 1000, function () use (&$error) {
$error = $error ?: new Exception('OBS WS request timed out');
Loop::stop();
});
$loop->run();
$loop->cancelTimer($timer);
if ($error) throw $error;
if (!$identified) throw new Exception('OBS WS never reached Identified');
return $result ?? [];
}
private function authString(string $salt, string $challenge): string
{
$secret = base64_encode(hash('sha256', $this->password . $salt, true));
return base64_encode(hash('sha256', $secret . $challenge, true));
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Services;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use RuntimeException;
/**
* Thin Twitch Helix client. Replaces twitch-bot/search-game.py + set-channel.py
* for use from `php artisan rig:loading`.
*
* Requires TWITCH_CLIENT_ID + TWITCH_TOKEN (user OAuth token with the
* channel:manage:broadcast scope) and TWITCH_BROADCASTER_ID in .env.
*/
class TwitchHelix
{
private Client $http;
private string $clientId;
private string $token;
private string $broadcasterId;
public function __construct()
{
$this->clientId = (string) config('rig.twitch.client_id');
$this->token = (string) config('rig.twitch.token');
$this->broadcasterId = (string) config('rig.twitch.broadcaster_id');
$this->http = new Client([
'base_uri' => 'https://api.twitch.tv/helix/',
'timeout' => 8.0,
]);
}
public function enabled(): bool
{
return $this->clientId !== '' && $this->token !== '';
}
/**
* Returns [['id' => '12345', 'name' => 'Half-Life 2'], ] for an exact-name match,
* or null if Twitch returned no matches.
*/
public function searchCategory(string $query): ?array
{
try {
$res = $this->http->get('search/categories', [
'headers' => $this->headers(),
'query' => ['query' => $query, 'first' => 20],
]);
} catch (GuzzleException $e) {
throw new RuntimeException('Twitch search failed: ' . $e->getMessage(), 0, $e);
}
$body = json_decode((string) $res->getBody(), true);
$hits = $body['data'] ?? [];
if (empty($hits)) return null;
// Prefer exact (case-insensitive) match; otherwise the first hit Twitch returned.
foreach ($hits as $row) {
if (strcasecmp((string) ($row['name'] ?? ''), $query) === 0) {
return ['id' => (string) $row['id'], 'name' => (string) $row['name']];
}
}
return ['id' => (string) $hits[0]['id'], 'name' => (string) $hits[0]['name']];
}
public function setChannel(string $title, ?string $gameId = null): void
{
if ($this->broadcasterId === '') {
throw new RuntimeException('TWITCH_BROADCASTER_ID not set in .env');
}
$body = ['title' => $title];
if ($gameId !== null && $gameId !== '') $body['game_id'] = $gameId;
try {
$this->http->patch('channels', [
'headers' => $this->headers() + ['Content-Type' => 'application/json'],
'query' => ['broadcaster_id' => $this->broadcasterId],
'json' => $body,
]);
} catch (GuzzleException $e) {
throw new RuntimeException('Twitch PATCH /helix/channels failed: ' . $e->getMessage(), 0, $e);
}
}
private function headers(): array
{
return [
'Authorization' => 'Bearer ' . $this->token,
'Client-Id' => $this->clientId,
];
}
}