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,30 @@
<?php
namespace App\Console\Commands;
use App\Services\ObsWsClient;
use Illuminate\Console\Command;
use Throwable;
class RigCmdCommand extends Command
{
protected $signature = 'rig:cmd {type : skip|prev|pause|resume}';
protected $description = 'Broadcast a music daemon command over OBS WS (CLI mirror of POST /cmd/{type})';
public function handle(ObsWsClient $ws): int
{
$type = strtolower((string) $this->argument('type'));
if (!in_array($type, ['skip', 'prev', 'pause', 'resume'], true)) {
$this->error('type must be one of: skip, prev, pause, resume');
return self::FAILURE;
}
try {
$ws->broadcast('mpd:cmd', ['type' => $type]);
} catch (Throwable $e) {
$this->error($e->getMessage());
return self::FAILURE;
}
$this->info("{$type}");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Console\Commands;
use App\Services\TwitchHelix;
use App\Support\RigData;
use Illuminate\Console\Command;
use Throwable;
class RigLoadingCommand extends Command
{
protected $signature = 'rig:loading {--no-twitch : Skip pushing the channel update to Twitch}';
protected $description = 'Interactive Project Loading manifest builder';
public function handle(RigData $rig, TwitchHelix $twitch): int
{
$prev = $rig->loading();
$this->line('── Project Loading manifest ──');
$this->line(' (press Enter to keep [defaults])');
$this->line('');
$game = null;
$gameId = null;
$defaultGame = (string) ($prev['game'] ?? '');
while (true) {
$query = (string) $this->ask('Game (search)', $defaultGame ?: null);
if ($query === '') {
$this->error(' ✗ Game is required');
continue;
}
if ($twitch->enabled()) {
$cachedId = (string) ($prev['gameId'] ?? '');
if ($query === $defaultGame && preg_match('/^\d+$/', $cachedId)) {
$game = $defaultGame;
$gameId = $cachedId;
$this->line(" ↻ reusing cached: {$game} (id={$gameId})");
break;
}
try {
$hit = $twitch->searchCategory($query);
} catch (Throwable $e) {
$this->warn(' ! Twitch search failed: ' . $e->getMessage());
if ($this->confirm('Use the raw input without resolving an id?', true)) {
$game = $query;
break;
}
continue;
}
if ($hit === null) {
$this->warn(' ! no Twitch matches for "' . $query . '"');
continue;
}
$game = $hit['name'];
$gameId = $hit['id'];
$this->line(" ↳ matched: {$game} (id={$gameId})");
break;
}
$game = $query;
break;
}
$subtitle = (string) $this->ask('Subtitle (mode / episode / note)', $prev['subtitle'] ?? null);
$countdown = (int) $this->ask('Countdown (minutes)', (string) ($prev['countdownMin'] ?? 5));
$camera = $this->confirm('Camera', (bool) ($prev['camera'] ?? true));
$microphone = $this->confirm('Microphone', (bool) ($prev['microphone'] ?? true));
$data = [
'compiledAt' => gmdate('Y-m-d\TH:i:s\Z'),
'game' => $game,
'gameId' => $gameId,
'subtitle' => $subtitle ?: null,
'countdownMin' => $countdown,
'camera' => $camera,
'microphone' => $microphone,
];
$path = rtrim(config('rig.storage_data'), '/') . '/loading.json';
@mkdir(dirname($path), 0775, true);
file_put_contents(
$path,
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"
);
$this->info('');
$this->info(" ✓ wrote {$path}");
// Push to Twitch — soft-fail so a network blip doesn't block the local manifest.
if (!$this->option('no-twitch') && $twitch->enabled() && $gameId) {
$title = $subtitle ?: $game;
try {
$twitch->setChannel($title, $gameId);
$this->info(" ✓ Twitch channel updated → \"{$title}\" / category {$game}");
} catch (Throwable $e) {
$this->warn(' ! Twitch sync failed (manifest still saved): ' . $e->getMessage());
}
}
$this->line('');
$this->line(' → refresh the Project Loading browser source in OBS.');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Process\Process;
use Throwable;
class RigPlaylistCommand extends Command
{
protected $signature = 'rig:playlist';
protected $description = 'Index audio under the configured music roots → storage/data/playlist.json';
private const EXTS = ['m4a', 'mp3', 'opus', 'ogg', 'flac', 'webm', 'aac', 'wav'];
public function handle(): int
{
$roots = array_values(array_filter((array) config('rig.music_dirs')));
if (empty($roots)) {
$this->warn(' ! no music dirs configured (set MUSIC_DIR_1 in .env)');
}
$ffprobe = (string) config('rig.ffprobe', '/usr/bin/ffprobe');
if (!is_executable($ffprobe)) {
$this->error(" ✗ ffprobe not found at {$ffprobe} (install ffmpeg or set FFPROBE_BIN)");
return self::FAILURE;
}
$this->line('── Indexing audio roots ──');
foreach ($roots as $r) {
$this->line(is_dir($r) ? " + {$r}" : " ! {$r} (missing — skipped)");
}
$tracks = [];
foreach ($roots as $root) {
if (!is_dir($root)) continue;
$finder = (new Finder())
->files()
->in($root)
->name('/\.(' . implode('|', self::EXTS) . ')$/i')
->ignoreUnreadableDirs()
->sortByName();
foreach ($finder as $file) {
$name = $file->getFilename();
$base = $file->getFilenameWithoutExtension();
// Skip yt-dlp intermediates (e.g. foo.f140.m4a, foo.temp.opus).
if (preg_match('/\.(?:f\d+|temp|part)$/i', $base)) continue;
$path = $file->getRealPath();
$meta = $this->probe($ffprobe, $path);
$tracks[] = [
'title' => $this->cleanTitle($base),
'file' => 'file://' . $this->urlEncodePath($path),
'durationSec' => isset($meta['durationSec']) ? (int) $meta['durationSec'] : null,
];
}
}
$doc = [
'syncedAt' => gmdate('Y-m-d\TH:i:s\Z'),
'sourceDirs' => $roots,
'trackCount' => count($tracks),
'tracks' => $tracks,
];
$path = rtrim(config('rig.storage_data'), '/') . '/playlist.json';
@mkdir(dirname($path), 0775, true);
file_put_contents(
$path,
json_encode($doc, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
);
$this->line('');
$this->info(" ✓ wrote {$path} ({$doc['trackCount']} tracks)");
$this->line(' → refresh the Music Daemon browser source in OBS to reload the queue.');
return self::SUCCESS;
}
private function probe(string $ffprobe, string $path): array
{
try {
$proc = new Process([
$ffprobe, '-v', 'error',
'-show_entries', 'format=duration:format_tags=title,artist',
'-of', 'json',
$path,
]);
$proc->setTimeout(10);
$proc->run();
if (!$proc->isSuccessful()) return [];
$json = json_decode($proc->getOutput(), true) ?: [];
$fmt = $json['format'] ?? [];
return [
'durationSec' => isset($fmt['duration']) ? (float) $fmt['duration'] : null,
];
} catch (Throwable) {
return [];
}
}
/**
* Mirrors scripts/playlist.sh's clean_title():
* - drop "| ..." (or fullwidth ) tails (genre/label noise)
* - strip trailing YouTube IDs in brackets like "[-XxZTgMWKV0]"
* - strip "[NCS Release]" / "(NCS10 Release)" suffixes
*/
private function cleanTitle(string $t): string
{
$t = preg_replace('/\s*[|].*$/u', '', $t) ?? $t;
$t = preg_replace('/\s*\[[A-Za-z0-9_-]{11}\]\s*$/', '', $t) ?? $t;
$t = preg_replace(
'/\s*[\[(](?:NCS\d*|No Copyright Sounds)(?:\s+Release)?[\])]\s*$/i',
'',
$t
) ?? $t;
return trim($t);
}
private function urlEncodePath(string $path): string
{
// Same as Python's urllib.parse.quote(..., safe='/') — keep separators, encode the rest.
return implode('/', array_map('rawurlencode', explode('/', $path)));
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class RigSetupCommand extends Command
{
protected $signature = 'rig:setup';
protected $description = 'Generate public/js/obs-config.js and OBS_WS_* env vars from plugin_config/obs-websocket/config.json';
public function handle(): int
{
$src = (string) config('rig.obs_ws_config_json');
if (!is_file($src)) {
$this->error("{$src} not found.");
$this->line(' Open OBS once with obs-websocket loaded so it generates the config.');
return self::FAILURE;
}
$cfg = json_decode((string) file_get_contents($src), true);
if (!is_array($cfg)) {
$this->error("{$src} did not parse as JSON.");
return self::FAILURE;
}
$port = (int) ($cfg['server_port'] ?? 4455);
$pass = (string) ($cfg['server_password'] ?? '');
$url = "ws://localhost:{$port}";
$out = (string) config('rig.obs_config_js');
@mkdir(dirname($out), 0775, true);
$js = <<<JS
// Auto-generated by `php artisan rig:setup`. Reflects the current contents of
// plugin_config/obs-websocket/config.json. Re-run if you rotate the password.
window.__OBSWS = {
url: {$this->jsString($url)},
password: {$this->jsString($pass)},
};
JS;
file_put_contents($out, $js);
$this->info(" ✓ wrote {$out}");
// Mirror into .env so MusicCommandController / ObsWsClient can authenticate.
$this->writeEnv('OBS_WS_URL', $url);
$this->writeEnv('OBS_WS_PASSWORD', $pass);
$this->info(' ✓ updated .env OBS_WS_URL / OBS_WS_PASSWORD');
$this->line('');
$this->line(" url = {$url}");
$this->line(' password = ' . ($pass === '' ? '(none)' : '(set, ' . strlen($pass) . ' chars)'));
$this->line('');
$this->line(' → if obs-webapp is running under supervisord, restart it so the new env takes effect:');
$this->line(' sudo supervisorctl restart obs-webapp');
return self::SUCCESS;
}
private function jsString(string $s): string
{
return json_encode($s, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
private function writeEnv(string $key, string $value): void
{
$env = base_path('.env');
$raw = is_file($env) ? file_get_contents($env) : '';
$line = $key . '=' . $this->escapeEnvValue($value);
if (preg_match("/^{$key}=.*$/m", $raw)) {
$raw = preg_replace("/^{$key}=.*$/m", $line, $raw);
} else {
$raw = rtrim($raw, "\n") . "\n" . $line . "\n";
}
file_put_contents($env, $raw);
}
private function escapeEnvValue(string $v): string
{
if ($v === '') return '';
if (preg_match('/[\s"#=]/', $v)) {
return '"' . str_replace('"', '\"', $v) . '"';
}
return $v;
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Console\Commands;
use App\Services\HardwareSnapshot;
use App\Support\RigData;
use Illuminate\Console\Command;
class RigTelemetryCommand extends Command
{
protected $signature = 'rig:telemetry {--collect : Re-read hardware + OBS profile (default: only refresh if file is missing)}';
protected $description = 'Capture rig hardware + OBS profile snapshot → storage/data/telemetry.json';
public function handle(HardwareSnapshot $snap, RigData $rig): int
{
$path = rtrim(config('rig.storage_data'), '/') . '/telemetry.json';
if ($this->option('collect') || !is_file($path)) {
$data = $snap->collect();
@mkdir(dirname($path), 0775, true);
file_put_contents(
$path,
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"
);
$this->info(" ✓ wrote {$path}");
} else {
$this->line(" · {$path} already exists — use --collect to re-read hardware.");
}
$current = $rig->telemetry();
$this->line('');
$this->line(" rig = " . ($current['rig'] ?? '?'));
$this->line(" cpu = " . ($current['cpu']['model'] ?? '?'));
if (!empty($current['gpu']['name'])) {
$this->line(" gpu = " . $current['gpu']['name']);
}
if (!empty($current['obs']['output']['w'])) {
$this->line(" output = {$current['obs']['output']['w']}×{$current['obs']['output']['h']} @ {$current['obs']['fps']} fps");
}
return self::SUCCESS;
}
}