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.
187 lines
6.7 KiB
PHP
187 lines
6.7 KiB
PHP
<?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;
|
|
}
|
|
}
|