diff --git a/basic/scenes/Default_Stream_HUD.json b/basic/scenes/Default_Stream_HUD.json index 285e7b9..ceeeca6 100644 --- a/basic/scenes/Default_Stream_HUD.json +++ b/basic/scenes/Default_Stream_HUD.json @@ -703,12 +703,12 @@ "id": 13, "group_item_backup": false, "pos": { - "x": 0.0, - "y": 0.0 + "x": 10.0, + "y": 10.0 }, "pos_rel": { - "x": -1.9161676168441772, - "y": -1.0 + "x": -1.9011975526809692, + "y": -0.98502993583679199 }, "scale": { "x": 1.0, @@ -719,12 +719,12 @@ "y": 1.0 }, "bounds": { - "x": 2560.0, - "y": 1336.0 + "x": 1985.0, + "y": 1213.0 }, "bounds_rel": { - "x": 3.8323352336883545, - "y": 2.0 + "x": 2.9715569019317627, + "y": 1.8158682584762573 }, "scale_filter": "disable", "blend_method": "default", @@ -850,7 +850,7 @@ { "name": "Camera", "source_uuid": "a8b362a1-91a6-491b-8751-26e2dad55ccd", - "visible": false, + "visible": true, "locked": false, "rot": 0.0, "scale_ref": { @@ -868,20 +868,20 @@ "id": 16, "group_item_backup": false, "pos": { - "x": 1990.0, - "y": 49.0 + "x": 2019.0, + "y": 20.0 }, "pos_rel": { - "x": 1.0628741979598999, - "y": -0.92664670944213867 + "x": 1.1062874794006348, + "y": -0.97005987167358398 }, "scale": { - "x": 0.28593748807907104, - "y": 0.28611111640930176 + "x": 0.27239581942558289, + "y": 0.27222222089767456 }, "scale_rel": { - "x": 0.23114706575870514, - "y": 0.23128743469715118 + "x": 0.22020021080970764, + "y": 0.22005987167358398 }, "bounds": { "x": 0.0, @@ -923,20 +923,20 @@ "id": 17, "group_item_backup": false, "pos": { - "x": 1984.0, - "y": 392.0 + "x": 2007.0, + "y": 353.0 }, "pos_rel": { - "x": 1.0538922548294067, - "y": -0.41317367553710938 + "x": 1.0883233547210693, + "y": -0.4715569019317627 }, "scale": { - "x": 1.0364298820495605, - "y": 1.0363636016845703 + "x": 0.99089258909225464, + "y": 0.99090909957885742 }, "scale_rel": { - "x": 0.68267840147018433, - "y": 0.68263471126556396 + "x": 0.65268373489379883, + "y": 0.65269464254379272 }, "bounds": { "x": 0.0, @@ -2602,8 +2602,8 @@ "name": "Good Bye" } ], - "current_scene": "Good Bye", - "current_program_scene": "Good Bye", + "current_scene": "Landing", + "current_program_scene": "Landing", "canvases": [], "current_transition": "Luma Wipe", "transition_duration": 1000, @@ -2635,7 +2635,7 @@ "saved_projectors": [], "preview_locked": false, "scaling_enabled": false, - "scaling_level": -13, + "scaling_level": -5, "scaling_off_x": 0.0, "scaling_off_y": 0.0, "modules": { diff --git a/desktop/index.html b/desktop/index.html index d8667be..9446689 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -26,6 +26,13 @@ --cam-w: 549px; --cam-h: 309px; --cam-pad: 10px; + /* Screen-capture frame transform — auto-synced from the Screen Capture + item the same way. Defaults match the current scene's bounds. */ + --scr-x: 10px; + --scr-y: 14px; + --scr-w: 1976px; + --scr-h: 1209px; + --scr-pad: 0px; } * { margin: 0; padding: 0; box-sizing: border-box; } @@ -92,17 +99,38 @@ 0 0 28px rgba(79, 210, 255, 0.20), inset 0 0 60px rgba(0, 0, 0, 0.55); } - .camera-frame .tick { + + /* ───────── Screen-capture frame ───────── + Same pattern as the camera frame — corner ticks + label + tracked size — + but transparent (the desktop renders behind, must show through) and a + thinner perimeter line, since at full-canvas scale a bright cyan rectangle + all around the desktop reads as too loud. Bright ticks carry the "this is + a framed feed" signal; the line itself is just a hint. */ + .screen-frame { + position: absolute; + top: calc(var(--scr-y) - var(--scr-pad)); + left: calc(var(--scr-x) - var(--scr-pad)); + width: calc(var(--scr-w) + var(--scr-pad) * 2); + height: calc(var(--scr-h) + var(--scr-pad) * 2); + pointer-events: none; + background: transparent; + border: 1px solid rgba(79, 210, 255, 0.30); + box-shadow: 0 0 18px rgba(79, 210, 255, 0.08); + } + + .camera-frame .tick, + .screen-frame .tick { position: absolute; width: 24px; height: 24px; border: 3px solid var(--hud-edge); box-shadow: 0 0 8px var(--hud-glow); } - .camera-frame .tick.tl { top: -2px; left: -2px; border-right: none; border-bottom: none; } - .camera-frame .tick.tr { top: -2px; right: -2px; border-left: none; border-bottom: none; } - .camera-frame .tick.bl { bottom: -2px; left: -2px; border-right: none; border-top: none; } - .camera-frame .tick.br { bottom: -2px; right: -2px; border-left: none; border-top: none; } - .camera-frame .label { + .camera-frame .tick.tl, .screen-frame .tick.tl { top: -2px; left: -2px; border-right: none; border-bottom: none; } + .camera-frame .tick.tr, .screen-frame .tick.tr { top: -2px; right: -2px; border-left: none; border-bottom: none; } + .camera-frame .tick.bl, .screen-frame .tick.bl { bottom: -2px; left: -2px; border-right: none; border-top: none; } + .camera-frame .tick.br, .screen-frame .tick.br { bottom: -2px; right: -2px; border-left: none; border-top: none; } + .camera-frame .label, + .screen-frame .label { position: absolute; top: 10px; left: 14px; font-family: var(--mono); @@ -133,7 +161,8 @@ letter-spacing: 0.20em; color: var(--hud-dim); } - .camera-frame.off { display: none; } + .camera-frame.off, + .screen-frame.off { display: none; } /* ───────── Bottom status bar ───────── */ .status-bar { @@ -203,6 +232,14 @@ +
+ + + + + SCR 01 +
+
@@ -273,53 +310,68 @@ } tickElapsed(); setInterval(tickElapsed, 1000); - // ── OBS WS: live camera frame sync ── - // URL params let you rename the camera source / scene without editing JS: - // ?camera=Webcam&scene=Desktop + // ── OBS WS: live frame sync for camera + screen capture ── + // URL params let you rename the sources / scene without editing JS: + // ?camera=Webcam&screen=Display&scene=Desktop const params = new URLSearchParams(location.search); const CAM_SOURCE = params.get('camera') || 'Camera'; - const CAM_SCENE = params.get('scene') || 'Desktop'; + const SCR_SOURCE = params.get('screen') || 'Screen Capture (PipeWire)'; + const SCENE = params.get('scene') || 'Desktop'; - const camEl = document.getElementById('cam'); + const camEl = document.getElementById('cam'); + const scrEl = document.getElementById('scr'); const rootStyle = document.documentElement.style; - function setCameraVisible(visible) { camEl.classList.toggle('off', !visible); } - // Map an OBS sceneItemTransform into the four CSS vars driving the frame. - // Assumes alignment 5 (top-left) — OBS's default for newly added items. - function setCameraTransform(t) { - if (!t) return; - const w = (t.sourceWidth ?? 0) * (t.scaleX ?? 1); - const h = (t.sourceHeight ?? 0) * (t.scaleY ?? 1); - if (!(w > 0 && h > 0)) return; - rootStyle.setProperty('--cam-x', `${t.positionX}px`); - rootStyle.setProperty('--cam-y', `${t.positionY}px`); - rootStyle.setProperty('--cam-w', `${w}px`); - rootStyle.setProperty('--cam-h', `${h}px`); + + // Compute rendered (w, h) on canvas from an OBS sceneItemTransform. + // Camera item has no bounds → scale × source. Screen capture uses + // bounds_type=stretch (1) → boundsWidth/boundsHeight are authoritative. + function renderedSize(t) { + const useBounds = t.boundsType + && t.boundsType !== 'OBS_BOUNDS_NONE' + && (t.boundsWidth ?? 0) > 0; + if (useBounds) return { w: t.boundsWidth, h: t.boundsHeight }; + return { + w: (t.sourceWidth ?? 0) * (t.scaleX ?? 1), + h: (t.sourceHeight ?? 0) * (t.scaleY ?? 1), + }; } - async function syncCamera(obs) { - let camItemId = null; + // Bind a frame element to a scene item: live-sync size/position into the + // --{prefix}-x/y/w/h CSS vars and toggle .off on enable changes. + // Assumes alignment 5 (top-left) — OBS's default for newly added items. + async function syncSourceFrame(obs, { sceneName, sourceName, frameEl, varPrefix }) { + const setVisible = (v) => frameEl.classList.toggle('off', !v); + const setTransform = (t) => { + if (!t) return; + const { w, h } = renderedSize(t); + if (!(w > 0 && h > 0)) return; + rootStyle.setProperty(`--${varPrefix}-x`, `${t.positionX}px`); + rootStyle.setProperty(`--${varPrefix}-y`, `${t.positionY}px`); + rootStyle.setProperty(`--${varPrefix}-w`, `${w}px`); + rootStyle.setProperty(`--${varPrefix}-h`, `${h}px`); + }; + let itemId = null; try { - const r = await obs.call('GetSceneItemId', - { sceneName: CAM_SCENE, sourceName: CAM_SOURCE }); - camItemId = r.sceneItemId; + const r = await obs.call('GetSceneItemId', { sceneName, sourceName }); + itemId = r.sceneItemId; const e = await obs.call('GetSceneItemEnabled', - { sceneName: CAM_SCENE, sceneItemId: camItemId }); - setCameraVisible(e.sceneItemEnabled); + { sceneName, sceneItemId: itemId }); + setVisible(e.sceneItemEnabled); const t = await obs.call('GetSceneItemTransform', - { sceneName: CAM_SCENE, sceneItemId: camItemId }); - setCameraTransform(t.sceneItemTransform); + { sceneName, sceneItemId: itemId }); + setTransform(t.sceneItemTransform); } catch (err) { - console.warn(`[desktop] camera sync init failed (${CAM_SCENE}/${CAM_SOURCE}):`, err.message); + console.warn(`[desktop] frame sync failed (${sceneName}/${sourceName}):`, err.message); return; } obs.addEventListener('event', (ev) => { const d = ev.detail || {}; - if (d.eventData?.sceneName !== CAM_SCENE - || d.eventData?.sceneItemId !== camItemId) return; + if (d.eventData?.sceneName !== sceneName + || d.eventData?.sceneItemId !== itemId) return; if (d.eventType === 'SceneItemEnableStateChanged') { - setCameraVisible(d.eventData.sceneItemEnabled); + setVisible(d.eventData.sceneItemEnabled); } else if (d.eventType === 'SceneItemTransformChanged') { - setCameraTransform(d.eventData.sceneItemTransform); + setTransform(d.eventData.sceneItemTransform); } }); } @@ -332,7 +384,11 @@ obs.addEventListener('close', () => { setTimeout(connectOBS, 2500); }); - await syncCamera(obs); + // Run both syncs in parallel — independent items, independent failures. + await Promise.all([ + syncSourceFrame(obs, { sceneName: SCENE, sourceName: CAM_SOURCE, frameEl: camEl, varPrefix: 'cam' }), + syncSourceFrame(obs, { sceneName: SCENE, sourceName: SCR_SOURCE, frameEl: scrEl, varPrefix: 'scr' }), + ]); } catch (e) { // OBS WS optional — silently retry; defaults stand meanwhile. setTimeout(connectOBS, 2500); diff --git a/landing/index.html b/landing/index.html index ea9e1ea..1c835bb 100644 --- a/landing/index.html +++ b/landing/index.html @@ -171,7 +171,12 @@ body { letter-spacing: 0.12em; } .foot .warn { color: var(--warn); } -#rig { transition: opacity 0.4s ease; } +/* Mirrors .station .rig.glitching — bottom telemetry uses the same scramble + reveal as the top rig identifier, so it shares the warn-yellow tint. */ +#rig.glitching { + color: var(--warn); + text-shadow: 0 0 10px rgba(255, 208, 0, 0.4); +} /* ───────── Overlays ───────── */ #static { @@ -323,55 +328,70 @@ body { // 8 fps — visually almost identical to 12 fps but ~33% less paint work. setInterval(drawNoise, 1000 / 8); - // ───────── Rig name in the top HUD with periodic glitch-decode ───────── - // Reads telemetry.js's window.__TEL.rig (set by telemetry.sh per-rig). - // Behaviour: settles on the real name, then every ~9s briefly scrambles - // each letter through random glyphs and resolves back left-to-right. Same - // effect family as the cyberpunk "decoder" trope; pairs with the CRT - // aesthetic rather than fighting it. - const rigNameEl = document.getElementById('rigName'); - const RIG_NAME = (window.__TEL && window.__TEL.rig) || 'UNKNOWN'; - // ASCII-only glyph pool — tabular_nums keeps width stable in DejaVu Mono. + // ───────── Cyberpunk decode/scramble reveal ───────── + // Each call: replace target text with random glyphs for `scrambleMs`, then + // resolve to `finalText` left-to-right. Same effect family as the cyberpunk + // "decoder" trope; pairs with the CRT aesthetic rather than fighting it. + // Used by both the top rig identifier and the bottom telemetry rotator. + // ASCII-only glyph pool — DejaVu Mono renders these at 1ch each, so the + // intermediate frames don't shift width. const GLITCH_GLYPHS = '01ABCDEFGHIJKLMNOPQRSTUVWXYZ#@*?!§%'; const glyph = () => GLITCH_GLYPHS[(Math.random() * GLITCH_GLYPHS.length) | 0]; - const upperRig = RIG_NAME.toUpperCase(); - - // Initial reveal: scramble for ~400ms, then resolve l→r. - // Subsequent: small idle scramble ~every 9s. - function scrambleOnce({ initial = false } = {}) { - const len = upperRig.length; - if (len === 0) { rigNameEl.textContent = '--'; return; } - rigNameEl.classList.add('glitching'); - const totalScrambleMs = initial ? 600 : 360; - const stepMs = 50; - const scrambleSteps = Math.max(1, Math.floor(totalScrambleMs / stepMs)); + // Tracks the in-flight scramble per element so a new call can cancel the + // previous one (otherwise back-to-back triggers double up timers). + const _scrambleHandles = new WeakMap(); + function scrambleReveal(el, finalText, opts = {}) { + const { scrambleMs = 360, stepMs = 50, resolveStepMs = 60 } = opts; + const prev = _scrambleHandles.get(el); + if (prev) { clearInterval(prev.s); clearInterval(prev.r); } + const len = finalText.length; + if (!len) { + el.textContent = ''; + el.classList.remove('glitching'); + _scrambleHandles.delete(el); + return; + } + el.classList.add('glitching'); + const scrambleSteps = Math.max(1, Math.floor(scrambleMs / stepMs)); + const handle = { s: null, r: null }; + _scrambleHandles.set(el, handle); let step = 0; - - const scrambleTimer = setInterval(() => { + handle.s = setInterval(() => { let s = ''; for (let i = 0; i < len; i++) s += glyph(); - rigNameEl.textContent = s; - if (++step >= scrambleSteps) { - clearInterval(scrambleTimer); - // Resolve left-to-right - let resolved = 0; - const resolveTimer = setInterval(() => { - resolved++; - let s = upperRig.slice(0, resolved); - for (let i = resolved; i < len; i++) s += glyph(); - rigNameEl.textContent = s; - if (resolved >= len) { - clearInterval(resolveTimer); - rigNameEl.textContent = upperRig; - rigNameEl.classList.remove('glitching'); - } - }, 60); - } + el.textContent = s; + if (++step < scrambleSteps) return; + clearInterval(handle.s); handle.s = null; + let resolved = 0; + handle.r = setInterval(() => { + resolved++; + let s = finalText.slice(0, resolved); + for (let i = resolved; i < len; i++) s += glyph(); + el.textContent = s; + if (resolved < len) return; + clearInterval(handle.r); handle.r = null; + el.textContent = finalText; + el.classList.remove('glitching'); + _scrambleHandles.delete(el); + }, resolveStepMs); }, stepMs); } + + // ───────── Rig name in the top HUD ───────── + // Reads telemetry.js's window.__TEL.rig (set by telemetry.sh per-rig). + // Settles on the real name, then idle-reglitches every ~9s. + const rigNameEl = document.getElementById('rigName'); + const RIG_NAME = (window.__TEL && window.__TEL.rig) || 'UNKNOWN'; + const upperRig = RIG_NAME.toUpperCase(); + const scrambleRig = ({ initial = false } = {}) => { + if (!upperRig.length) { rigNameEl.textContent = '--'; return; } + scrambleReveal(rigNameEl, upperRig, { + scrambleMs: initial ? 600 : 360, stepMs: 50, resolveStepMs: 60, + }); + }; // First reveal happens shortly after page load; then idle re-glitch. - setTimeout(() => scrambleOnce({ initial: true }), 600); - setInterval(scrambleOnce, 9000); + setTimeout(() => scrambleRig({ initial: true }), 600); + setInterval(scrambleRig, 9000); // ───────── Telemetry: rotating real-data subtitle ───────── // All lines derive from telemetry.js (window.__TEL), generated by telemetry.sh. @@ -432,24 +452,31 @@ body { ]; let lineIdx = 0; - const renderLine = () => { + // Pick the next non-null line, advancing lineIdx past dead slots. + const nextLine = () => { for (let i = 0; i < lines.length; i++) { const v = lines[lineIdx](); - if (v) { rigEl.textContent = v; return; } + if (v) return v; lineIdx = (lineIdx + 1) % lines.length; } - rigEl.textContent = '— TELEMETRY OFFLINE —'; + return '— TELEMETRY OFFLINE —'; }; - // Soft crossfade: 0.4s out → swap text → 0.4s in (matches the CSS transition) - const FADE_MS = 400; + // Initial paint goes in directly so the page loads with content visible; + // every subsequent transition uses the scramble reveal (same effect as the + // top rig identifier). + rigEl.textContent = nextLine(); + // Bottom lines are much longer than the rig name (~22–55 chars vs ~5–8), + // so adapt the per-char resolve step to keep total reveal under ~700ms. const swapLine = () => { - rigEl.style.opacity = '0'; - setTimeout(() => { renderLine(); rigEl.style.opacity = '1'; }, FADE_MS); + const text = nextLine(); + const len = Math.max(text.length, 1); + scrambleReveal(rigEl, text, { + scrambleMs: 280, + stepMs: 45, + resolveStepMs: Math.max(15, Math.floor(700 / len)), + }); }; - - renderLine(); - rigEl.style.opacity = '1'; setInterval(() => { lineIdx = (lineIdx + 1) % lines.length; swapLine(); diff --git a/landing/telemetry.json b/landing/telemetry.json index 54a4938..c9589f2 100644 --- a/landing/telemetry.json +++ b/landing/telemetry.json @@ -2,14 +2,14 @@ "collectedAt": "2026-04-26T20:17:38Z", "rig": "Ignia", "cpu": { - "model": "AMD Ryzen 5 2600 Six-Core Processor", + "model": "AMD Ryzen 5", "threads": 12 }, "mem": { - "totalGB": 31.3 + "totalGB": 32 }, "host": { - "kernel": "6.18.23-1-lts" + "kernel": "Arch Linux 6.18.23-1-lts" }, "gpu": { "name": "NVIDIA GeForce RTX 3060 Ti", diff --git a/user.ini b/user.ini index f9bbcdc..094beea 100644 --- a/user.ini +++ b/user.ini @@ -25,8 +25,8 @@ cx=720 cy=580 [BasicWindow] -geometry=AdnQywADAAAAAAoAAAAAAAAAEX8AAAQDAAAAAAAAAAAAAAO/AAAEAwAAAAACAAAAB4AAAAoAAAAAAAAAEX8AAAQD -DockState=AAAA/wAAAAD9AAAAAQAAAAMAAAeAAAABKPwBAAAABvsAAAAUAHMAYwBlAG4AZQBzAEQAbwBjAGsBAAAAAAAAAWEAAACYAP////sAAAAWAHMAbwB1AHIAYwBlAHMARABvAGMAawEAAAFlAAABXwAAAJgA////+wAAABIAbQBpAHgAZQByAEQAbwBjAGsBAAACyAAAAgoAAAECAP////sAAAAeAHQAcgBhAG4AcwBpAHQAaQBvAG4AcwBEAG8AYwBrAQAABNYAAAFdAAAArQD////7AAAAGABjAG8AbgB0AHIAbwBsAHMARABvAGMAawEAAAY3AAABSQAAAKIA////+wAAABIAcwB0AGEAdABzAEQAbwBjAGsCAAAExAAAAXAAAAK8AAAAyAAAB4AAAAKcAAAABAAAAAQAAAAIAAAACPwAAAAA +geometry=AdnQywADAAAAAAAAAAAAAAAACf8AAAVDAAAKAQAAABkAAA3AAAAEHAAAAAECAAAACgAAAAAAAAAAAAAACf8AAAVD +DockState=AAAA/wAAAAD9AAAAAQAAAAMAAAoAAAABKPwBAAAABvsAAAAUAHMAYwBlAG4AZQBzAEQAbwBjAGsBAAAAAAAAAdgAAACYAP////sAAAAWAHMAbwB1AHIAYwBlAHMARABvAGMAawEAAAHcAAAB1QAAAJgA////+wAAABIAbQBpAHgAZQByAEQAbwBjAGsBAAADtQAAAroAAAECAP////sAAAAeAHQAcgBhAG4AcwBpAHQAaQBvAG4AcwBEAG8AYwBrAQAABnMAAAHSAAAArQD////7AAAAGABjAG8AbgB0AHIAbwBsAHMARABvAGMAawEAAAhJAAABtwAAAKIA////+wAAABIAcwB0AGEAdABzAEQAbwBjAGsCAAAExAAAAXAAAAK8AAAAyAAACgAAAAPcAAAABAAAAAQAAAAIAAAACPwAAAAA PreviewEnabled=true AlwaysOnTop=false SceneDuplicationMode=true