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,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)));
}
}