bunch of shit
This commit is contained in:
102
dock/index.html
102
dock/index.html
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user