diff --git a/CHANGELOG.md b/CHANGELOG.md
index bb06ab0..830910f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,35 @@ 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
+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
@@ -31,22 +60,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/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);
}
diff --git a/web/index.html b/web/index.html
index 1a97c80..669a6ed 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.
+
+ Heads-up: Enhanced Broadcasting needs an enabled Twitch destination with a stream key. OBS will still launch, but EB won't engage until you add one in the Destinations tab.
+
+
+
+
+
+
+
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.
+
+
+
@@ -2420,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.
@@ -2669,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;
@@ -3256,6 +3303,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 +4041,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 +4065,7 @@
}
// 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 +4116,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