From 41b78804c9fbe1f649ab74b18afcd9bb51fa060f Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Sun, 5 Jul 2026 11:51:12 +0200 Subject: [PATCH 1/3] feat: OBS quick launch (EB / EB+VOD) from the System tab The Enhanced Broadcasting launchers only existed inside the Twitch destination editor, behind the VOD-audio toggle - starting OBS pre-wired meant opening a destination's settings every time. System -> Behavior now has an "OBS quick launch" card with: - Launch OBS - Enhanced Broadcasting (session-only --config-url launch) - Launch OBS - EB + VOD audio track - Make a desktop shortcut (the existing VOD+EB cold-start .lnk) The EB+VOD button does one thing the old flow got for free from the form: it persists vod_audio=true on the enabled Twitch destination before writing OBS's flag. That's required for durability, not cosmetics - reconcile_obs_vod_files derives OBS's on-disk flag from the destinations on every save, so a launch that skipped the destination flag would be silently reverted by the next save. Each step reports into the same red-to-green checklist as the form flow (renderer extracted and shared). The card warns inline when no enabled Twitch destination with a key exists (OBS launches, but EB has nothing to engage) and the buttons disable with a reason when OBS isn't installed (probe cached per session, refreshed via loadDestinations so destination changes update the warning live). No backend changes - reuses /obs/launch-with-eb, /obs/setup-vod-eb, /shortcut/create-eb, and /destinations. 235 tests green; verified the new functions survive build.rs minification in the embedded output. --- CHANGELOG.md | 20 +++++++ web/index.html | 152 +++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 162 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb06ab0..dd43164 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ All notable changes will land here. Format loosely follows ## [Unreleased] +### OBS quick launch from the System tab + +The Enhanced Broadcasting launchers no longer hide inside the Twitch +destination editor. **System → Behavior → OBS quick launch** now offers: + +- **Launch OBS · Enhanced Broadcasting** - starts OBS with the + `--config-url` flag pointed at InstantClone, session-only. +- **Launch OBS · EB + VOD audio track** - same, plus it enables VOD + audio mode on your enabled Twitch destination and writes OBS's + VOD-track unlock flag, reporting each step as a red-to-green + checklist. Persisting the destination flag matters: OBS's on-disk + flag is derived from the destinations on every save, so a launch that + skipped it would be silently reverted later. +- **Make a desktop shortcut** - the existing VOD+EB cold-start shortcut, + now reachable without opening a destination. + +The card warns inline when no enabled Twitch destination with a stream +key exists (OBS still launches, but EB has nothing to engage), and the +buttons disable with a reason when OBS isn't installed. + ### Smooth aurora + hero text state transitions The hero now changes state as one coordinated sweep - glow, number, and diff --git a/web/index.html b/web/index.html index 1a97c80..c523fa2 100644 --- a/web/index.html +++ b/web/index.html @@ -2026,6 +2026,26 @@

New destination

+ + +
+
OBS quick launch
+
Start OBS pre-wired for Twitch Enhanced Broadcasting - no destination editing needed. The launch is session-only: closing OBS and reopening it normally streams plain again.
+ +
+ + + +
+
The EB + VOD button also turns on VOD audio mode on your Twitch destination (so the OBS setting survives future saves) and writes OBS's VOD-track unlock flag - close OBS before pressing it or the flag write is blocked.
+ +
+
@@ -3256,6 +3276,9 @@

New destination

async function loadDestinations(){ try { lastDests = await (await fetch('/destinations')).json(); rebuildDests(); } catch(_){} + // Keep the System tab's quick-launch warning/buttons in sync with the + // current destination set (covers startup and every dest change). + refreshSysEbLaunch(); } // ── Destination form ────────────────────────────────────────────────── @@ -3991,6 +4014,17 @@

New destination

// One-click VOD + EB: saves the destination (so the VOD flag is written), // then runs the server-side setup and renders a red-to-green checklist so // the user can see exactly which step needs attention. +// Render a red-to-green step checklist (shared by the destination-form +// and System-tab VOD+EB flows). +function renderEbChecklist(list, steps){ + if (!list || !steps) return; + list.innerHTML = steps.map(s => + `
  • + ${s.ok?'✓':'✗'} + ${s.name} - ${s.msg} +
  • `).join(''); +} + async function setupVodEb(){ const btn = $('obs-eb-setup-btn'); const list = $('vod-eb-checklist'); @@ -4004,13 +4038,7 @@

    New destination

    } try { const r = await (await fetch('/obs/setup-vod-eb', {method:'POST'})).json(); - if (list && r.steps){ - list.innerHTML = r.steps.map(s => - `
  • - ${s.ok?'✓':'✗'} - ${s.name} - ${s.msg} -
  • `).join(''); - } + if (r.steps) renderEbChecklist(list, r.steps); toast(r.ok ? 'VOD + EB ready' : 'VOD + EB needs attention', r.ok ? 'ok' : 'err'); } catch(e){ if (list) list.innerHTML = '
  • Request failed: '+String(e)+'
  • '; @@ -4020,9 +4048,10 @@

    New destination

    } // Create a Desktop shortcut that cold-starts the whole VOD + EB flow. -async function createEbShortcut(){ - const btn = $('eb-shortcut-btn'); - const msg = $('eb-shortcut-msg'); +// Callable from the destination form (default ids) and the System tab. +async function createEbShortcut(btnId, msgId){ + const btn = $(btnId || 'eb-shortcut-btn'); + const msg = $(msgId || 'eb-shortcut-msg'); if (!btn || btn.disabled) return; btn.disabled = true; if (msg) msg.textContent = 'Creating shortcut…'; @@ -4060,6 +4089,109 @@

    New destination

    } catch(_){} } +// ── System tab: OBS quick launch ────────────────────────────────────── +// EB / VOD+EB launchers that work without opening any destination's +// settings. They reuse the same backend endpoints as the destination +// form; the only extra logic is the VOD+EB pre-step that persists +// vod_audio on the Twitch destination - without it, the next +// destination save would flip OBS's VOD flag straight back off +// (reconcile_obs_vod_files derives the flag from the destinations). + +function firstTwitchDest(){ + return (lastDests || []).find(d => d.enabled && d.platform === 'twitch' && d.stream_key_set); +} + +// OBS-installed probe, cached for the session - the exe doesn't +// appear/disappear between clicks, and loadDestinations() calls this +// on every refresh. +let _obsInstalled = null; +async function refreshSysEbLaunch(){ + const warn = $('sys-eb-twitch-warn'); + if (warn) warn.hidden = !!firstTwitchDest(); + if (_obsInstalled === null){ + try { _obsInstalled = !!(await (await fetch('/obs/launch-status')).json()).installed; } catch(_){} + } + const dis = _obsInstalled === false; + ['sys-eb-btn','sys-vodeb-btn'].forEach(id => { + const b = $(id); + if (b){ b.disabled = dis; b.className = dis ? 'ic-btn ic-btn-disabled' : 'ic-btn ic-btn-primary'; } + }); + if (dis){ + const msg = $('sys-eb-msg'); + if (msg && !msg.textContent) msg.textContent = 'OBS not found at the standard install path.'; + } +} + +async function sysLaunchEb(){ + const btn = $('sys-eb-btn'); + if (!btn || btn.disabled) return; + btn.disabled = true; + const msg = $('sys-eb-msg'); + const list = $('sys-vodeb-checklist'); + if (list) list.hidden = true; + try { + const r = await (await fetch('/obs/launch-with-eb', {method:'POST'})).json(); + if (msg) msg.textContent = r.ok ? r.message : (r.error || 'Launch failed'); + toast(r.ok ? 'OBS launched with Enhanced Broadcasting' : 'Launch failed', r.ok ? 'ok' : 'err'); + } catch(e){ + if (msg) msg.textContent = String(e); + toast('Request failed','err'); + } + btn.disabled = false; +} + +async function sysLaunchVodEb(){ + const btn = $('sys-vodeb-btn'); + if (!btn || btn.disabled) return; + btn.disabled = true; + const list = $('sys-vodeb-checklist'); + const msg = $('sys-eb-msg'); + if (msg) msg.textContent = ''; + if (list){ list.hidden = false; list.innerHTML = '
  • Preparing…
  • '; } + const steps = []; + try { + const d = firstTwitchDest(); + if (!d){ + renderEbChecklist(list, [{name:'Twitch destination', ok:false, + msg:'none enabled with a stream key - add one in the Destinations tab first'}]); + toast('Add a Twitch destination first','err'); + btn.disabled = false; + return; + } + if (d.vod_audio){ + steps.push({name:'VOD audio mode', ok:true, msg:`already on for "${d.name}"`}); + } else { + // Same full-field resend discipline as toggleDest: anything not + // passed through resets to its default on the server. + const body = new URLSearchParams({ + id:d.id, name:d.name, platform:d.platform, + custom_egress_url:d.custom_egress_url||'', twitch_ingest:d.twitch_ingest||'', + youtube_ingest:d.youtube_ingest||'', + vod_audio:'on', + vod_audio_inject_eb: d.vod_audio_inject_eb ? 'on' : 'off', + stream_format:d.stream_format||'horizontal', enabled:'on', + }); + const rr = await (await fetch('/destinations', {method:'POST', body})).json(); + steps.push({name:'VOD audio mode', ok:!!rr.ok, + msg: rr.ok ? `enabled on "${d.name}"` : (rr.error || 'destination update failed')}); + if (!rr.ok){ + renderEbChecklist(list, steps); + toast('Could not update the Twitch destination','err'); + btn.disabled = false; + return; + } + loadDestinations(); + } + const r = await (await fetch('/obs/setup-vod-eb', {method:'POST'})).json(); + renderEbChecklist(list, steps.concat(r.steps || [])); + toast(r.ok ? 'VOD + EB ready' : 'VOD + EB needs attention', r.ok ? 'ok' : 'err'); + } catch(e){ + if (list) list.innerHTML = '
  • Request failed: '+String(e)+'
  • '; + toast('Request failed','err'); + } + btn.disabled = false; +} + // ── Reset settings / Reset everything ──────────────────────────────── // Two-step destructive flow. The "settings" scope is recoverable in // practice (the user can just re-enter their port/buffer/webhook From 69458eac30fa3c08724fa55a68f5938c60ccb326 Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Sun, 5 Jul 2026 13:39:17 +0200 Subject: [PATCH 2/3] ultrathink: the delay number never actually animated - fix at the root Proven with a simulation: animateNumber() has been a direct-set its entire life. It only stored per-element state on the animate path, but a fresh id can never reach that path - the throwaway object it builds always has `to === target`, so it hits the no-change branch and returns without seeding numState. The map stays empty forever, every call rebuilds a fresh matching object, and the number snaps to each value. This is why my earlier ease/linear/snap tweaks did nothing (dead code on a function that can't animate) and why the user still saw the arming "jump" and the disarm instant-zero. Two fixes: 1. animateNumber now seeds numState on first sight of an id (show the value, store state), so subsequent changes reach the animate path. The stat readouts ease properly now too - also never animated before. 2. The hero delay figure gets a dedicated critically-damped spring (Unity SmoothDamp) on a single persistent rAF that chases the latest target. It tracks the buffer fill at smooth, near-constant velocity while arming (no jerk from tweening a varying gap over a fixed duration) and eases the big jumps - especially the roll-down to 0 on cut/disarm - to rest with no overshoot, no snap, no freeze, immune to the state-update cadence. Verified the roll-down and count-up numerically before wiring it in. 235 tests green; verified the new code in the actual built exe over HTTP. --- CHANGELOG.md | 30 +++++++------- web/index.html | 107 +++++++++++++++++++++++++++++++------------------ 2 files changed, 81 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd43164..1a30ac3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,22 +51,20 @@ is gone. the auto-cut countdown update up to 4x/s - each tick used to replay the fade, a constant pulse). Message changes get a cleaner directional swap: the old line drops out, the new one drops in. -- The arming counter counts continuously instead of stuttering: it - animates linearly with a duration longer than the update cadence, so - it never finishes early and freezes between ticks (the old 80 ms - ease-out sprinted then froze ~170 ms every update). The fill bar - fills at constant speed, and phase jumps (arm / activate / cut) keep - a longer eased sweep. Retargeting mid-flight no longer stacks - duplicate animation loops on the element. -- Fixed the counter SNAPPING instead of animating. `animateNumber` is - called with the same target on every state tick, and its no-change - branch hard-set the text to the end value - killing any roll in - progress. That was the "disarm 15s→0s with no animation" report: the - idle state repeats ~4x/s and each repeat snapped the count-down to 0. - It now lets an in-flight animation finish. -- Disarm / cancel rolls the big number down to 0.0 over the same 1.1 s - as the color sweep, so the count-down and the fade-to-idle land as - one motion instead of the number vanishing ahead of the glow. +- The big delay number now actually animates - it never did. The shared + `animateNumber` helper only stored per-element state on its animate + path, which a fresh id could never reach (its throwaway object always + had `to === target`), so the state map stayed empty and every number + SNAPPED to each value. The counter has been a direct-set since it was + written; the arming "stutter" and the "disarm 15s→0s with no + animation" were both this. The helper now seeds its map on first + sight, so the stat readouts ease properly too. +- The hero delay figure is driven by a dedicated critically-damped + spring (SmoothDamp) on one persistent rAF, not a per-update tween. It + tracks the buffer fill at smooth near-constant velocity while arming + (no jerk from retargeting a varying gap over a fixed time) and eases + the big jumps - notably the roll DOWN to 0 on cut / disarm - to rest + with no overshoot, snap, or freeze, independent of update cadence. - The hero delay-profile chips and the Profiles pane no longer flicker. Both rebuilt their whole DOM on every state tick (~4x/s); they now skip the rebuild unless the rendered content actually changed. diff --git a/web/index.html b/web/index.html index c523fa2..669a6ed 100644 --- a/web/index.html +++ b/web/index.html @@ -2440,45 +2440,80 @@

    New destination

    } // ── Animated number helper ──────────────────────────────────────────── -const numState = new Map(); // id → {from, to, start} -// `ease`: 'linear' for values that stream in continuously (the arming -// fill arrives ~4x/s - each segment must hand off to the next at -// constant velocity or the count reads as a stutter); default cubic -// ease-out for one-shot jumps (arm, activate, cut). -function animateNumber(id, target, dur, fmt, ease){ +const numState = new Map(); // id → {to, last, gen} +// Fixed-duration ease-out tween for the stat readouts. NOTE the fix: the +// map is now SEEDED on first sight of an id. The old version only stored +// state on the animate path, which a fresh id could never reach (its +// throwaway object always had `to === target`), so the map stayed empty +// and every number SNAPPED - animateNumber never actually animated. +function animateNumber(id, target, dur, fmt){ const el = $(id); if(!el) return; - const cur = numState.get(id) || { from: target, to: target, start: 0, last: target }; - if (cur.to === target){ - // Same target as the current animation. If one is still in flight - // (last hasn't reached target), LET IT FINISH - writing the final - // text here would snap to the end and kill the visible motion. - // That was the "disarm 15s→0s with no animation" bug: the idle - // state repeats ~4x/s, and each repeat re-entered here mid-roll and - // hard-set the text to 0.0. Only commit text once actually settled. - if (cur.last === target){ - el.textContent = fmt ? fmt(target) : Math.round(target).toLocaleString(); - } + const fmtv = v => fmt ? fmt(v) : Math.round(v).toLocaleString(); + let cur = numState.get(id); + if (!cur){ // first sighting: show + seed, no anim + numState.set(id, { to: target, last: target, gen: 0 }); + el.textContent = fmtv(target); return; } + if (cur.to === target){ // already heading here + if (cur.last === target) el.textContent = fmtv(target); // settled: exact + return; // in-flight: let the running loop finish + } cur.from = cur.last; cur.to = target; cur.start = performance.now(); - // Generation guard: a retarget mid-flight abandons the older rAF loop - // instead of leaving two loops writing the same element every frame - // (arming retargets ~4x/s with a 300 ms duration, so loops overlap). - cur.gen = (cur.gen || 0) + 1; - const gen = cur.gen; - numState.set(id, cur); + const gen = (cur.gen = (cur.gen || 0) + 1); const tick = (now) => { - if (cur.gen !== gen) return; + if (cur.gen !== gen) return; // a newer retarget owns the element now const t = Math.min(1, (now - cur.start) / (dur || 600)); - const eased = ease === 'linear' ? t : 1 - Math.pow(1 - t, 3); - const v = cur.from + (cur.to - cur.from) * eased; - cur.last = v; - el.textContent = fmt ? fmt(v) : Math.round(v).toLocaleString(); + const v = cur.from + (cur.to - cur.from) * (1 - Math.pow(1 - t, 3)); + cur.last = v; el.textContent = fmtv(v); if (t < 1) requestAnimationFrame(tick); }; requestAnimationFrame(tick); } +// ── Hero delay number: continuous critically-damped motion ──────────── +// The big delay figure is special: it COUNTS UP as the buffer fills +// (target moves ~4x/s) and ROLLS DOWN on cut/disarm (one big jump). A +// fixed-duration tween handles the jump but makes the count-up jerk - +// each state update retargets a varying gap over a fixed time, so the +// velocity keeps changing. Instead we run ONE persistent rAF that chases +// the latest target with a critically-damped spring (Unity SmoothDamp): +// it tracks a moving target at smooth, near-constant velocity (buttery +// count-up) and eases a big jump to rest with no overshoot (clean +// roll-down) - immune to update cadence, with no snap and no freeze. +const heroNum = { cur: 0, vel: 0, target: 0, smooth: 0.3, prev: 0, raf: 0 }; +function setHeroDelayNumber(sec, smoothTime){ + heroNum.target = sec; + heroNum.smooth = smoothTime || 0.3; + if (!heroNum.raf){ + heroNum.prev = performance.now(); + heroNum.raf = requestAnimationFrame(heroNumTick); + } +} +function heroNumTick(now){ + const el = $('delay-big'); + if (!el){ heroNum.raf = 0; return; } + const dt = Math.min(0.05, Math.max(0.001, (now - heroNum.prev) / 1000)); + heroNum.prev = now; + const smoothTime = Math.max(0.02, heroNum.smooth); + const omega = 2 / smoothTime; + const x = omega * dt; + const exp = 1 / (1 + x + 0.48*x*x + 0.235*x*x*x); + const to = heroNum.target; + const change = heroNum.cur - to; + const temp = (heroNum.vel + omega * change) * dt; + heroNum.vel = (heroNum.vel - omega * temp) * exp; + let out = to + (change + temp) * exp; + // Overshoot clamp (SmoothDamp): if we crossed the target, land on it. + if ((to - heroNum.cur > 0) === (out > to)){ out = to; heroNum.vel = (out - to) / dt; } + heroNum.cur = out; + el.textContent = heroNum.cur.toFixed(1); + if (Math.abs(heroNum.cur - to) < 0.02 && Math.abs(heroNum.vel) < 0.1){ + heroNum.cur = to; el.textContent = to.toFixed(1); heroNum.raf = 0; return; + } + heroNum.raf = requestAnimationFrame(heroNumTick); +} + // ── Sparkline ───────────────────────────────────────────────────────── // Catmull-Rom -> cubic-bezier smoothing so the trace reads as a smooth // curve instead of a jagged polyline. @@ -2689,18 +2724,10 @@

    New destination

    const showSec = ds === 'arming' ? Math.min(fillSec, targetSec) : ds === 'armed' || ds === 'active' ? targetSec : 0; - // Arming counts LINEARLY, and the duration must be >= the state-update - // cadence or the number finishes early and FREEZES until the next - // update - the stutter you see. SSE pushes ~4x/s, but the polling - // fallback is every 500 ms, so 600 ms guarantees the animation is - // still running when the next value lands (with the generation guard, - // a retarget mid-flight just continues at constant speed). Phase jumps - // get an eased sweep; the roll DOWN to zero (disarm / cancel) takes - // 1.1 s to match the aurora + number color sweep, so the count-down - // and the fade-to-idle land together. - const numDur = ds === 'arming' ? 600 : showSec === 0 ? 1100 : 700; - animateNumber('delay-big', showSec, numDur, v => v.toFixed(1), - ds === 'arming' ? 'linear' : null); + // Continuous smooth motion (see setHeroDelayNumber). Tighter spring + // while arming so the count-up tracks the buffer fill closely; a touch + // softer for the big jumps (arm / activate / cut / disarm roll-down). + setHeroDelayNumber(showSec, ds === 'arming' ? 0.22 : 0.34); if (ds === 'arming'){ $('delay-of').hidden = false; $('delay-of').textContent = `/ ${targetSec.toFixed(1)}s`; $('arm-bar').hidden = false; From d1035a952a5e9900670be1bf413466cb266e9fe7 Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Sun, 5 Jul 2026 14:30:51 +0200 Subject: [PATCH 3/3] fix: dock showed a phantom 1s delay in passthrough; review cleanup The OBS dock rendered its big number from current_delay_ms, which includes the pipeline's natural transit latency (encoder -> ingest -> ring -> egress) even when no delay is armed - so passthrough read as "1.0s". It now mirrors the dashboard: armed target when a delay is engaged (fill while arming), 0 in passthrough. Review pass over the whole merge set: clippy clean (no Rust dead code), no dead JS functions, no leftover references from the animation iterations, dashboard animations are read-only (never POST, cannot desync the stream state), and the dock is a standalone file unaffected by the dashboard changes. --- CHANGELOG.md | 9 +++++++++ web/dock.html | 10 ++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a30ac3..830910f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ All notable changes will land here. Format loosely follows ## [Unreleased] +### Fixes + +- The OBS dock showed a phantom **1.0s** delay in passthrough. It fed + the big number from `current_delay_ms`, which includes the pipeline's + own transit latency (encoder → ingest → ring → egress) even with no + delay armed. The dock now mirrors the dashboard: the armed target + while a delay is engaged (or the fill while arming), and 0 in + passthrough. + ### OBS quick launch from the System tab The Enhanced Broadcasting launchers no longer hide inside the Twitch diff --git a/web/dock.html b/web/dock.html index 16310fa..d4d30e5 100644 --- a/web/dock.html +++ b/web/dock.html @@ -223,8 +223,14 @@ // Source pill - encoder connected or not (any RTMP encoder, not just OBS) $('src').className='pill '+(s.ingest_alive?'on':'bad'); $('src-lbl').textContent=s.ingest_alive?'Live':'Offline'; - // Current delay - const nv=fmt(s.current_delay_ms); + // Current delay - mirror the dashboard: show the armed target while a + // delay is engaged (or the fill while arming) and 0 in passthrough. + // Using current_delay_ms directly showed the pipeline's own ~1s + // transit latency as "1.0s" even in passthrough, a phantom delay. + const tgtSec=(s.armed_delay_ms||0)/1000, fillSec=(s.buffer_fill_ms||0)/1000; + const showSec = ds==='arming' ? Math.min(fillSec,tgtSec) + : (ds==='armed'||ds==='active') ? tgtSec : 0; + const nv=fmt(showSec*1000); if(nv!==lastVal){$('v').textContent=nv;lastVal=nv; $('v').parentElement.classList.add('changing');setTimeout(()=>$('v').parentElement.classList.remove('changing'),350); }