Landing: telemetry collector, doomguard watermark, SIGNAL centering

- telemetry.sh: bash collector for static device + OBS profile specs.
  Default mode wraps telemetry.json → telemetry.js (script-tag-loaded
  global, dodging file:// fetch CORS in CEF). --collect re-reads
  hardware + OBS basic.ini / streamEncoder.json.
- index.html: footer subtitle now rotates real CPU/MEM/GPU/OBS data
  with soft 400ms crossfade; SIGNAL group re-centered via 3-col grid;
  doomguard watermark behind everything with periodic "tune-in"
  spikes through the noise canvas.
- telemetry.json: hand-edited cosmetic version of collected specs.
- .gitignore: track telemetry.json (source), ignore telemetry.js
  (build artifact) and *.manual backups.
This commit is contained in:
Jakub Zych
2026-04-26 00:47:59 +02:00
parent 6decec66cc
commit 704e126adc
4 changed files with 450 additions and 4 deletions

11
.gitignore vendored
View File

@@ -20,6 +20,17 @@ plugin_config/obs-websocket/
*.env *.env
.env* .env*
# ============================================================
# Machine-local generated data
# ============================================================
# Generated wrapper for telemetry.json — built by telemetry.sh, not edited.
# (The JSON itself is tracked: it's the hand-edited source for the wrapper.)
landing/telemetry.js
# Local backups created during edits — keep out of git noise.
landing/*.manual
# ============================================================ # ============================================================
# OBS runtime / generated state — no value in version control # OBS runtime / generated state — no value in version control
# ============================================================ # ============================================================

View File

@@ -36,8 +36,8 @@ body {
/* ───────── HUD top strip ───────── */ /* ───────── HUD top strip ───────── */
.hud { .hud {
display: flex; display: grid;
justify-content: space-between; grid-template-columns: 1fr 1fr 1fr;
align-items: center; align-items: center;
font-family: var(--mono); font-family: var(--mono);
font-size: 22px; font-size: 22px;
@@ -46,6 +46,9 @@ body {
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
.hud .group { display: flex; gap: 28px; align-items: center; } .hud .group { display: flex; gap: 28px; align-items: center; }
.hud > .group:nth-child(1) { justify-self: start; }
.hud > .group:nth-child(2) { justify-self: center; }
.hud > .group:nth-child(3) { justify-self: end; }
.hud .dim { color: var(--hud-dim); } .hud .dim { color: var(--hud-dim); }
.hud .led { .hud .led {
display: inline-block; display: inline-block;
@@ -99,7 +102,7 @@ body {
text-align: center; text-align: center;
} }
.standby .big { .standby .big {
font-size: 172px; font-size: 130px;
font-weight: 900; font-weight: 900;
letter-spacing: 0.20em; letter-spacing: 0.20em;
line-height: 1; line-height: 1;
@@ -129,6 +132,68 @@ body {
letter-spacing: 0.12em; letter-spacing: 0.12em;
} }
.foot .warn { color: var(--warn); } .foot .warn { color: var(--warn); }
#rig { transition: opacity 0.4s ease; }
/* ───────── Doomguard watermark ─────────
Sits at z-index: -1 — below all flow content (HUD, standby, etc.)
and below the static canvas, so the white noise paints on top of it.
Mostly buried under noise (~4% opacity, blurred); periodic "tune-in"
spikes raise opacity + sharpen blur briefly, like a CRT catching signal. */
.watermark {
position: absolute;
top: 50%;
left: 50%;
width: 38vw;
height: auto;
transform: translate(-50%, -50%);
pointer-events: none;
user-select: none;
z-index: -1;
opacity: 0;
filter: blur(2px) brightness(0.9);
animation: logo-tune-in 27s linear infinite;
}
/* Logo is invisible by default. Three irregular "almost-catches-signal"
events per loop, each preceded by stutter (rapid opacity flips) and
tiny vertical jitter — like a CRT trying to sync. Max opacity ~13%,
always blurred — never crisp, always reads as buried in the noise. */
@keyframes logo-tune-in {
/* — Long quiet — */
0%, 22% { opacity: 0; filter: blur(2px) brightness(0.9); transform: translate(-50%, -50%); }
/* Event 1: stutter in, brief soft hold (~9% peak), stutter out */
22.3% { opacity: 0.06; filter: blur(1.4px) brightness(1.0); transform: translate(-50%, -50.3%); }
22.6% { opacity: 0; filter: blur(2px) brightness(0.9); transform: translate(-50%, -49.8%); }
23% { opacity: 0.09; filter: blur(0.8px) brightness(1.1); transform: translate(-50%, -50%); }
24.6% { opacity: 0.09; filter: blur(0.8px) brightness(1.1); transform: translate(-50%, -50%); }
25% { opacity: 0; filter: blur(2px) brightness(0.9); }
25.3% { opacity: 0.04; }
25.6% { opacity: 0; }
/* — Long quiet — */
44% { opacity: 0; transform: translate(-50%, -50%); }
/* Event 2: split-second flutter, signal almost catches but loses it (~5% peak) */
44.2% { opacity: 0.05; filter: blur(1.4px) brightness(1.0); transform: translate(-50.3%, -50%); }
44.5% { opacity: 0; transform: translate(-50%, -50%); }
/* — Quiet — */
68% { opacity: 0; transform: translate(-50%, -50%); filter: blur(2px) brightness(0.9); }
/* Event 3: stuttered emergence to longer hold (~13% peak), stuttered exit */
68.3% { opacity: 0.05; filter: blur(2px) brightness(0.95); transform: translate(-49.7%, -50%); }
68.6% { opacity: 0; }
69% { opacity: 0.08; filter: blur(1px) brightness(1.05); transform: translate(-50%, -50.2%); }
70.5% { opacity: 0.13; filter: blur(0.4px) brightness(1.15); transform: translate(-50%, -50%); }
72% { opacity: 0.10; filter: blur(0.6px) brightness(1.1); }
73% { opacity: 0; filter: blur(2px) brightness(0.9); }
73.3% { opacity: 0.05; }
73.6% { opacity: 0; }
/* — Quiet to end — */
100% { opacity: 0; transform: translate(-50%, -50%); }
}
/* ───────── Overlays ───────── */ /* ───────── Overlays ───────── */
#static { #static {
@@ -183,6 +248,11 @@ body {
</head> </head>
<body> <body>
<img class="watermark"
src="file:///home/jin/cloud.jakubzych.com/_img/logos/dgw.png"
alt=""
aria-hidden="true">
<header class="hud"> <header class="hud">
<div class="group"> <div class="group">
<span><span class="led"></span>TRANSMISSION</span> <span><span class="led"></span>TRANSMISSION</span>
@@ -222,7 +292,7 @@ body {
<footer class="foot"> <footer class="foot">
<span>CH 118.0 MHz</span> <span>CH 118.0 MHz</span>
<span>NTSC &middot; 525i &middot; MONO</span> <span id="rig">&mdash; TELEMETRY &mdash;</span>
<span class="warn">&#x25B2; AUDIO ACTIVE</span> <span class="warn">&#x25B2; AUDIO ACTIVE</span>
</footer> </footer>
@@ -231,6 +301,11 @@ body {
<div class="vignette"></div> <div class="vignette"></div>
<div class="flicker"></div> <div class="flicker"></div>
<!-- Static device specs as a global. Generated by telemetry.sh.
Loaded via <script> instead of fetch() because OBS's CEF blocks
file:// → file:// fetch (Chromium "unique origin" rule). -->
<script src="telemetry.js"></script>
<script> <script>
// Live UTC clock // Live UTC clock
const clockEl = document.getElementById('clock'); const clockEl = document.getElementById('clock');
@@ -269,6 +344,88 @@ body {
}; };
drawNoise(); drawNoise();
setInterval(drawNoise, 1000 / 12); setInterval(drawNoise, 1000 / 12);
// ───────── Telemetry: rotating real-data subtitle ─────────
// All lines derive from telemetry.js (window.__TEL), generated by telemetry.sh.
const rigEl = document.getElementById('rig');
const tel = window.__TEL || null;
// Compact CPU model: drop "(R)/(TM)" and "X-Core Processor" filler
const shortCpu = (m) => (m || '')
.replace(/\(R\)|\(TM\)/g, '')
.replace(/\s+\S+-Core\s+Processor\b/i, '')
.replace(/\s+Processor\b/i, '')
.replace(/\s+CPU\b.*$/i, '')
.replace(/\s+@.*$/, '')
.replace(/\s+/g, ' ')
.trim();
const lines = [
// CPU + MEM — one combined line
() => {
if (!tel?.cpu?.model) return null;
let s = `CPU · ${shortCpu(tel.cpu.model)}`;
if (tel.cpu.threads) s += ` · ${tel.cpu.threads}T`;
if (tel.mem?.totalGB) s += ` · MEM ${tel.mem.totalGB} GB`;
return s;
},
// GPU
() => {
if (!tel?.gpu?.name) return null;
let s = `GPU · ${tel.gpu.name}`;
if (tel.gpu.vramTotalMB) s += ` · ${(tel.gpu.vramTotalMB / 1024).toFixed(0)} GB VRAM`;
return s;
},
// OBS output: resolution, fps, encoder
() => {
const o = tel?.obs;
if (!o?.output?.w) return null;
let s = `OUTPUT · ${o.output.w}×${o.output.h}`;
if (o.fps) s += ` @ ${o.fps} FPS`;
if (o.stream?.encoder) s += ` · ${o.stream.encoder}`;
return s;
},
// OBS stream: rate control + bitrate + h.264 profile
() => {
const s = tel?.obs?.stream;
if (!s) return null;
const parts = [];
if (s.rateControl) parts.push(s.rateControl);
if (s.bitrateKbps) parts.push(`${s.bitrateKbps} kbps`);
if (s.profile) parts.push(s.profile.toUpperCase());
return parts.length ? `STREAM · ${parts.join(' · ')}` : null;
},
// Kernel
() => tel?.host?.kernel ? `KERNEL · ${tel.host.kernel}` : null,
];
let lineIdx = 0;
const renderLine = () => {
for (let i = 0; i < lines.length; i++) {
const v = lines[lineIdx]();
if (v) { rigEl.textContent = v; return; }
lineIdx = (lineIdx + 1) % lines.length;
}
rigEl.textContent = '— TELEMETRY OFFLINE —';
};
// Soft crossfade: 0.4s out → swap text → 0.4s in (matches the CSS transition)
const FADE_MS = 400;
const swapLine = () => {
rigEl.style.opacity = '0';
setTimeout(() => { renderLine(); rigEl.style.opacity = '1'; }, FADE_MS);
};
renderLine();
rigEl.style.opacity = '1';
setInterval(() => {
lineIdx = (lineIdx + 1) % lines.length;
swapLine();
}, 6000);
</script> </script>
</body> </body>
</html> </html>

41
landing/telemetry.json Normal file
View File

@@ -0,0 +1,41 @@
{
"collectedAt": "2026-04-25T22:10:52Z",
"cpu": {
"model": "AMD Ryzen",
"threads": 12
},
"mem": {
"totalGB": 32
},
"host": {
"kernel": "Arch Linux 6.18.23-1-lts"
},
"gpu": {
"name": "NVIDIA GeForce RTX 3060 Ti",
"vramTotalMB": 8192
},
"obs": {
"profile": "Untitled",
"renderer": "OpenGL",
"outputMode": "Advanced",
"canvas": { "w": 2560, "h": 1336 },
"output": { "w": 1920, "h": 1080 },
"fps": 30,
"color": {
"format": "NV12",
"space": "709",
"range": "Partial"
},
"stream": {
"encoder": "x264",
"rateControl": "CBR",
"bitrateKbps": null,
"keyintSec": 2,
"profile": "high"
},
"audio": {
"sampleRateHz": 48000,
"channels": "Stereo"
}
}
}

237
landing/telemetry.sh Executable file
View File

@@ -0,0 +1,237 @@
#!/usr/bin/env bash
# Static device + OBS config snapshot for the OBS Landing overlay.
#
# Two modes:
# ./telemetry.sh — wrap telemetry.json → telemetry.js
# (idempotent; preserves any hand edits to the JSON)
# ./telemetry.sh --collect — re-read hardware + OBS config, overwrite
# telemetry.json, then wrap → telemetry.js
#
# Use --collect after a hardware change, kernel update, or OBS settings change.
# For cosmetic tweaks just edit telemetry.json by hand and re-run with no args.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OBS_DIR="$(cd "$DIR/.." && pwd)"
JSON="$DIR/telemetry.json"
JS="$DIR/telemetry.js"
sanitize() {
printf '%s' "${1:-}" \
| tr -d '"\\' \
| tr '\n\r\t' ' ' \
| sed 's/ */ /g; s/^ //; s/ $//'
}
emit_str() { [[ -z "${1:-}" ]] && printf 'null' || printf '"%s"' "$1"; }
emit_num() { [[ -z "${1:-}" ]] && printf 'null' || printf '%s' "$1"; }
# ── INI reader (section-scoped) ─────────────────────
ini_get() {
# Usage: ini_get FILE SECTION KEY → prints value (empty if missing)
local file="$1" section="$2" key="$3"
[[ -f "$file" ]] || return 0
awk -v section="[$section]" -v key="$key" '
$0 == section { in_section = 1; next }
/^\[/ { in_section = 0; next }
in_section && index($0, key"=") == 1 {
sub("^[^=]*=", "")
print
exit
}
' "$file"
}
# Friendlier names for the encoder ids OBS stores in basic.ini
prettify_encoder() {
case "${1:-}" in
obs_x264|x264) echo "x264";;
jim_nvenc|ffmpeg_nvenc|obs_nvenc_h264_tex|obs_nvenc_h264_soft) echo "NVENC H.264";;
obs_nvenc_hevc_tex|obs_nvenc_hevc_soft) echo "NVENC HEVC";;
obs_nvenc_av1_tex|obs_nvenc_av1_soft) echo "NVENC AV1";;
obs_qsv11) echo "QSV";;
*) echo "${1:-}";;
esac
}
# Pull a top-level field out of a flat JSON object via python3.
json_field() {
local file="$1" field="$2"
[[ -f "$file" ]] || return 0
python3 - "$file" "$field" <<'PY' 2>/dev/null || true
import json, sys
try:
with open(sys.argv[1]) as f:
d = json.load(f)
v = d.get(sys.argv[2])
print('' if v is None else v)
except Exception:
pass
PY
}
# ── OBS config block ────────────────────────────────
collect_obs() {
local user_ini="$OBS_DIR/user.ini"
[[ -f "$user_ini" ]] || { echo "null"; return; }
local profile renderer
profile=$(ini_get "$user_ini" "Basic" "Profile")
renderer=$(ini_get "$user_ini" "Video" "Renderer")
[[ -z "$profile" ]] && profile="Untitled"
local profile_dir="$OBS_DIR/basic/profiles/$profile"
local ini="$profile_dir/basic.ini"
[[ -f "$ini" ]] || { echo "null"; return; }
local mode base_w base_h out_w out_h fps
mode=$(ini_get "$ini" "Output" "Mode")
base_w=$(ini_get "$ini" "Video" "BaseCX")
base_h=$(ini_get "$ini" "Video" "BaseCY")
out_w=$(ini_get "$ini" "Video" "OutputCX")
out_h=$(ini_get "$ini" "Video" "OutputCY")
fps=$(ini_get "$ini" "Video" "FPSInt")
local color_fmt color_space color_range
color_fmt=$(ini_get "$ini" "Video" "ColorFormat")
color_space=$(ini_get "$ini" "Video" "ColorSpace")
color_range=$(ini_get "$ini" "Video" "ColorRange")
local sample_rate ch_setup
sample_rate=$(ini_get "$ini" "Audio" "SampleRate")
ch_setup=$(ini_get "$ini" "Audio" "ChannelSetup")
# Stream encoder + bitrate. Path differs Simple vs Advanced.
local enc_raw="" bitrate="" rc="" keyint="" h264_profile=""
if [[ "$mode" == "Advanced" ]]; then
enc_raw=$(ini_get "$ini" "AdvOut" "Encoder")
local sej="$profile_dir/streamEncoder.json"
rc=$(json_field "$sej" "rate_control")
bitrate=$(json_field "$sej" "bitrate")
keyint=$(json_field "$sej" "keyint_sec")
h264_profile=$(json_field "$sej" "profile")
else
enc_raw=$(ini_get "$ini" "SimpleOutput" "StreamEncoder")
bitrate=$(ini_get "$ini" "SimpleOutput" "VBitrate")
fi
local enc_pretty
enc_pretty=$(prettify_encoder "$enc_raw")
profile=$(sanitize "$profile")
renderer=$(sanitize "$renderer")
mode=$(sanitize "$mode")
color_fmt=$(sanitize "$color_fmt")
color_space=$(sanitize "$color_space")
color_range=$(sanitize "$color_range")
ch_setup=$(sanitize "$ch_setup")
enc_pretty=$(sanitize "$enc_pretty")
rc=$(sanitize "$rc")
h264_profile=$(sanitize "$h264_profile")
cat <<JSON
{
"profile": $(emit_str "$profile"),
"renderer": $(emit_str "$renderer"),
"outputMode": $(emit_str "$mode"),
"canvas": { "w": $(emit_num "$base_w"), "h": $(emit_num "$base_h") },
"output": { "w": $(emit_num "$out_w"), "h": $(emit_num "$out_h") },
"fps": $(emit_num "$fps"),
"color": {
"format": $(emit_str "$color_fmt"),
"space": $(emit_str "$color_space"),
"range": $(emit_str "$color_range")
},
"stream": {
"encoder": $(emit_str "$enc_pretty"),
"rateControl": $(emit_str "$rc"),
"bitrateKbps": $(emit_num "$bitrate"),
"keyintSec": $(emit_num "$keyint"),
"profile": $(emit_str "$h264_profile")
},
"audio": {
"sampleRateHz": $(emit_num "$sample_rate"),
"channels": $(emit_str "$ch_setup")
}
}
JSON
}
# ── Hardware specs ──────────────────────────────────
collect() {
local cpu_model cpu_threads
cpu_model=$(awk -F': ' '/^model name/ {print $2; exit}' /proc/cpuinfo)
cpu_threads=$(nproc)
local mem_total_kb mem_total_g
mem_total_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)
mem_total_g=$(awk -v k="$mem_total_kb" 'BEGIN {printf "%.1f", k/1024/1024}')
local kernel; kernel=$(uname -r)
local gpu_name="" gpu_vram_total_mb=""
if command -v nvidia-smi >/dev/null 2>&1; then
local q
q=$(nvidia-smi --query-gpu=name,memory.total \
--format=csv,noheader,nounits 2>/dev/null | head -n1 || true)
if [[ -n "$q" ]]; then
IFS=',' read -r gpu_name gpu_vram_total_mb <<<"$q"
gpu_name=$(sanitize "$gpu_name")
gpu_vram_total_mb=$(printf '%s' "$gpu_vram_total_mb" | tr -d ' ')
fi
fi
cpu_model=$(sanitize "$cpu_model")
kernel=$(sanitize "$kernel")
local now_iso; now_iso=$(date -u +%FT%TZ)
local obs_block; obs_block=$(collect_obs)
local tmp="$JSON.tmp.$$"
cat > "$tmp" <<JSON
{
"collectedAt": "$now_iso",
"cpu": {
"model": $(emit_str "$cpu_model"),
"threads": $(emit_num "$cpu_threads")
},
"mem": {
"totalGB": $(emit_num "$mem_total_g")
},
"host": {
"kernel": $(emit_str "$kernel")
},
"gpu": {
"name": $(emit_str "$gpu_name"),
"vramTotalMB": $(emit_num "$gpu_vram_total_mb")
},
"obs": $obs_block
}
JSON
# Validate before promoting — catches any malformed substitution.
if ! python3 -m json.tool "$tmp" >/dev/null 2>&1; then
echo "collect: produced invalid JSON, leaving $tmp for inspection" >&2
exit 1
fi
mv -f "$tmp" "$JSON"
echo "wrote $JSON"
}
# Wraps telemetry.json as `window.__TEL = {...};` so index.html can load it
# via a <script> tag. fetch() on file:// is blocked in CEF; script-tag isn't.
wrap_js() {
if [[ ! -f "$JSON" ]]; then
echo "telemetry.json not found — run with --collect first" >&2
exit 1
fi
if ! python3 -m json.tool "$JSON" >/dev/null 2>&1; then
echo "telemetry.json is not valid JSON — fix it before re-wrapping" >&2
exit 1
fi
local tmp="$JS.tmp.$$"
{ printf 'window.__TEL = '; cat "$JSON"; printf ';\n'; } > "$tmp"
mv -f "$tmp" "$JS"
echo "wrote $JS"
}
[[ "${1:-}" == "--collect" ]] && collect
wrap_js