bunch of shit

This commit is contained in:
2026-11-06 19:05:47 -06:00
parent ca01516d42
commit 630b253278
29 changed files with 2793 additions and 303 deletions

View File

@@ -30,6 +30,12 @@
.kv .k{color:var(--mut)}
.tok{letter-spacing:1px}
.who{color:var(--mut);font-size:12px}
/* post-Go-Live health check: what the server is actually receiving */
#health{margin-top:10px;padding:8px;border-radius:6px;font-size:13px;white-space:pre-wrap;word-break:break-word}
#health.hidden{display:none}
#health.bad{background:#43242a;color:#ffd0d0;border:1px solid var(--bad)}
#health.bad code{background:#1a1a1a;padding:1px 5px;border-radius:4px;color:var(--fg)}
#health.ok{color:var(--ok)}
</style>
</head>
<body>
@@ -53,6 +59,11 @@
<label>Ingest host</label><input id="host" placeholder="media.example.com">
<label>OBS WebSocket password</label><input id="obspw" type="password">
<label>OBS WebSocket port</label><input id="obsport" value="4455">
<label>OBS profile to stream from</label><input id="profile" value="Vanguard">
<label style="display:flex;gap:8px;align-items:center;margin-top:8px"><input type="checkbox" id="enforce" style="width:auto;margin:0"> switch OBS to this profile when the dock loads</label>
<div class="who">Encoder settings (B-frames, keyframe interval) live in an OBS profile and obs-websocket cannot change them,
but it can switch profiles. Create one named <b>Vanguard</b> once: Profile → New, then Output → Advanced → your encoder
with <b>B-frames 0</b> and keyframe interval 2 s. Go Live always switches to it; this box also does it on startup.</div>
<label>Title prefix (optional, e.g. <code>HelmetCam-</code>)</label><input id="prefix" placeholder="">
<label>Title suffix (optional; <code>{date}</code> becomes YYYY-MM-DD)</label><input id="suffix" placeholder="-{date}">
<button id="save">Save settings</button>
@@ -69,6 +80,7 @@
<button class="small" id="tc_remove">Remove band</button>
</details>
<div id="health" class="hidden"></div>
<details id="manual">
<summary>Manual OBS setup (endpoints + token)</summary>
<div class="kv"><span class="k">RTMP server </span><span id="m_rtmp">—</span></div>
@@ -85,11 +97,12 @@
<script>
// ---- settings --------------------------------------------------------------
const KEYS = ["host","obspw","obsport","prefix","suffix"];
const KEYS = ["host","obspw","obsport","prefix","suffix","profile"];
const BOOLS = ["enforce"];
const $ = id => document.getElementById(id);
const status = (msg, cls) => { $("status").textContent = msg; $("status").style.color = cls==="bad"?"#e74c3c":cls==="ok"?"#2ecc71":"#9a9a9a"; };
function load(){ try{ KEYS.forEach(k => { const v = localStorage.getItem("vg."+k); if (v!==null) $(k).value = v; }); }catch(e){} if(!$("host").value) $("host").value = location.hostname; }
function save(){ try{ KEYS.forEach(k => localStorage.setItem("vg."+k, $(k).value.trim())); status("settings saved"); }catch(e){ status("could not save settings (storage blocked)", "bad"); } renderManual(); }
function load(){ try{ KEYS.forEach(k => { const v = localStorage.getItem("vg."+k); if (v!==null) $(k).value = v; }); BOOLS.forEach(k => { $(k).checked = localStorage.getItem("vg."+k) === "1"; }); }catch(e){} if(!$("host").value) $("host").value = location.hostname; }
function save(){ try{ KEYS.forEach(k => localStorage.setItem("vg."+k, $(k).value.trim())); BOOLS.forEach(k => localStorage.setItem("vg."+k, $(k).checked ? "1" : "0")); status("settings saved"); }catch(e){ status("could not save settings (storage blocked)", "bad"); } renderManual(); }
load(); $("save").onclick = save;
const cfg = () => Object.fromEntries(KEYS.map(k => [k, $(k).value.trim()]));
@@ -99,7 +112,7 @@ function buildTitle(raw){
const c = cfg();
let t = (c.prefix||"") + raw.trim() + (c.suffix||"").replace("{date}", today());
t = t.replace(/\s+/g, "-") // spaces -> dashes
.replace(/[^A-Za-z0-9_.-]/g, "") // allowed characters only
.replace(/[^A-Za-z0-9_-]/g, "") // allowed characters only; no dots: <title>.webrtc is the audio mirror's name
.replace(/_{2,}/g, "_") // "__" is reserved: it separates title from timestamp in filenames
.replace(/^[.-]+/, "") // no leading dot (hidden) or dash
.slice(0, 64);
@@ -108,7 +121,7 @@ function buildTitle(raw){
function ruleText(raw){
const r = [];
if (/__/.test(raw)) r.push("double underscore is reserved (becomes single)");
if (/[^A-Za-z0-9_.\- ]/.test(raw)) r.push("only letters, digits, space, _ . - are kept");
if (/[^A-Za-z0-9_\- ]/.test(raw)) r.push("only letters, digits, space, _ - are kept");
if (raw.trim().startsWith(".")) r.push("cannot start with a dot");
if ((cfg().prefix + raw + cfg().suffix).length > 64) r.push("longer than 64 characters: trimmed");
return r.join(" · ");
@@ -177,6 +190,28 @@ function obs(c){
});
}
// ---- profile enforcement ----------------------------------------------------------------------------
// Encoder settings (B-frames above all: MediaMTX refuses them over WebRTC) cannot be set through
// obs-websocket in Advanced output mode — they live in the profile's streamEncoder.json, which the protocol
// does not expose. Profiles can be switched, though, so the setting is enforced by making sure OBS is on a
// profile that already has it. The post-Go-Live health check then verifies the actual encode.
// Returns true when OBS is on the wanted profile, false (with a status line saying why) otherwise.
async function ensureProfile(o, c) {
if (!c.profile) return true;
const { profiles, currentProfileName } = await o.call("GetProfileList");
if (!profiles.includes(c.profile)) {
status(`OBS has no profile named "${c.profile}". Create it once (Profile → New) with B-frames 0, or clear the name in settings. Streaming on "${currentProfileName}".`, "bad");
return false;
}
if (currentProfileName === c.profile) return true;
const st = await o.call("GetStreamStatus");
if (st.outputActive) { status(`OBS is on "${currentProfileName}" but streaming; not switching mid-stream`, "bad"); return false; }
await o.call("SetCurrentProfile", { profileName: c.profile });
await new Promise(r => setTimeout(r, 800)); // OBS reloads its outputs on a profile switch
status(`OBS switched to profile "${c.profile}"`, "ok");
return true;
}
// ---- timecode band (obs-websocket) -------------------------------------------------------------------
const CLOCK_INPUT = "Vanguard Clock";
const bandFor = w => { let b = Math.round(w / 160); return b + (b % 2); };
@@ -260,15 +295,66 @@ $("golive").onclick = async () => {
const t = await mint(); // fresh token every Go Live; nothing stored
status("connecting to OBS…");
const o = await obs(c);
await ensureProfile(o, c); // the Vanguard profile carries B-frames 0; switch first
await o.call("SetStreamServiceSettings", { streamServiceType: "rtmp_custom",
streamServiceSettings: { server: `rtmp://${c.host}:1935/`, key: `${t.user}/${title}?user=${encodeURIComponent(t.user)}&pass=${encodeURIComponent(t.token)}`, use_auth: false } });
// B-frames make the stream unwatchable on the portal (MediaMTX refuses them over WebRTC). obs-websocket
// can only set encoder options in Simple output mode with x264, so do that much here; everything else is
// caught after start by checkHealth(), which reads the actual recording on the server.
try {
const mode = (await o.call("GetProfileParameter", { parameterCategory: "Output", parameterName: "Mode" })).parameterValue;
if (mode === "Simple") {
const enc = (await o.call("GetProfileParameter", { parameterCategory: "SimpleOutput", parameterName: "StreamEncoder" })).parameterValue || "";
if (/x264/i.test(enc)) {
const cur = (await o.call("GetProfileParameter", { parameterCategory: "SimpleOutput", parameterName: "x264Settings" })).parameterValue || "";
if (!/\b(bf|bframes)=0\b/.test(cur)) {
await o.call("SetProfileParameter", { parameterCategory: "SimpleOutput", parameterName: "x264Settings", parameterValue: (cur + " bf=0").trim() });
status("set x264 bf=0 in OBS (Simple output) so viewers can watch over WebRTC");
}
}
}
} catch (e) { /* older obs-websocket, or a locked profile: the health check will say if it matters */ }
const st = await o.call("GetStreamStatus");
if (st.outputActive) { await o.call("StopStream"); await new Promise(r => setTimeout(r, 1500)); }
await o.call("StartStream"); o.close();
status(`LIVE → ${t.user}/${title}`, "ok");
checkHealth(90);
} catch (e) { status(e.message, "bad"); }
$("golive").disabled = false;
};
// After Go Live, ask the server what it is actually receiving. The recording's first fragments reveal the
// B-frame depth within seconds, and B-frames mean every WebRTC viewer is refused. Polls until the stream
// shows up and is judged, or for `seconds`, whichever comes first.
let healthTimer = null;
async function checkHealth(seconds) {
clearInterval(healthTimer);
const until = Date.now() + seconds * 1000, box = $("health");
box.className = "who"; box.textContent = "checking what the server is receiving…";
healthTimer = setInterval(async () => {
try {
const r = await fetch("/me/stream-health", { credentials: "same-origin" });
if (!r.ok) throw new Error("HTTP " + r.status);
const d = await r.json();
const judged = (d.streams || []).filter(s => s.bframes != null);
if (judged.length) {
clearInterval(healthTimer);
const bad = judged.filter(s => !s.webrtc_ok);
if (bad.length) {
box.className = "bad";
box.innerHTML = `<b>Viewers cannot watch this stream over WebRTC.</b> Your encoder is sending B-frames `
+ `(${bad.map(s => `${s.profile || "H.264"} ${s.res || ""}, B-frame depth ${s.bframes}`).join("; ")}). `
+ `In OBS: Output → Advanced → <b>x264</b>: add <code>bf=0</code> to the options; <b>NVENC</b>: Max B-frames = 0; `
+ `<b>AMF/QSV</b>: B-frames = 0. Then stop and start the stream. The recording itself is fine.`;
} else {
box.className = "ok";
box.textContent = `server receiving ${judged.map(s => `${s.res || ""} ${s.fps ? s.fps + " fps" : ""}`.trim()).join(", ")} with no B-frames — WebRTC viewers are good`;
}
return;
}
} catch (e) { /* not up yet, or the dock is not behind the proxy; keep trying until the deadline */ }
if (Date.now() > until) { clearInterval(healthTimer); box.className = "who"; box.textContent = "could not confirm what the server is receiving (is the stream up?)"; }
}, 6000);
}
$("stop").onclick = async () => { try { const o = await obs(cfg()); await o.call("StopStream"); o.close(); status("stream stopped"); } catch (e) { status(e.message, "bad"); } };
let armed = 0;
@@ -290,6 +376,7 @@ $("dump").onclick = async () => {
} catch (e) { status("dump failed: " + e.message, "bad"); }
};
$("signout").onclick = async e => {
e.preventDefault(); status("signing out…");
// Authentik's sign-out redirects to the site root (copyparty); do it in the background, then reload the dock,
@@ -304,6 +391,11 @@ $("signout").onclick = async e => {
await whoami();
try { await mint(); status("ready"); }
catch (e) { status(e.message, "bad"); $("m_tok").textContent = "— " + e.message; }
// "switch OBS to this profile when the dock loads": inside OBS the dock loads with OBS, so this is startup
if ($("enforce").checked) {
try { const o = await obs(cfg()); await ensureProfile(o, cfg()); o.close(); }
catch (e) { status(`profile check on startup: ${e.message}`, "bad"); }
}
})();
renderPreview();
</script>