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:
30
webapp/app/Console/Commands/RigCmdCommand.php
Normal file
30
webapp/app/Console/Commands/RigCmdCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
101
webapp/app/Console/Commands/RigLoadingCommand.php
Normal file
101
webapp/app/Console/Commands/RigLoadingCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
125
webapp/app/Console/Commands/RigPlaylistCommand.php
Normal file
125
webapp/app/Console/Commands/RigPlaylistCommand.php
Normal 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)));
|
||||
}
|
||||
}
|
||||
84
webapp/app/Console/Commands/RigSetupCommand.php
Normal file
84
webapp/app/Console/Commands/RigSetupCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
42
webapp/app/Console/Commands/RigTelemetryCommand.php
Normal file
42
webapp/app/Console/Commands/RigTelemetryCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user