diff --git a/CHANGELOG.md b/CHANGELOG.md index d3c8e3d..bb06ab0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,66 @@ All notable changes will land here. Format loosely follows ## [Unreleased] +### Smooth aurora + hero text state transitions + +The hero now changes state as one coordinated sweep - glow, number, and +pill move together - and the per-second stutter in the text and counter +is gone. + +- The two aurora colors are registered CSS custom properties + (`@property`), so a state change interpolates the colors themselves + over ~1.1 s - the old `transition: background` never worked because + gradients aren't transitionable as images. Browsers without + `@property` keep the previous instant swap. +- The drift animation is now identical in every state. The old "arming" + speed-up overrode `animation-duration` (14 s → 4 s) mid-flight, which + remaps elapsed time and visibly teleported the blobs on every + enter/exit of buffering. Arming's energy is a new dedicated breathing + layer instead: a bottom glow that fades in over 0.9 s and pulses + gently (transform only, so its restart never fights the fade). +- The big delay number's color rides the same 1.1 s sweep as the aurora + (it used to snap a second ahead of the glow), and the state pill's + text/background/border colors sweep too instead of hard-cutting. +- The subtext under the number only animates when the MESSAGE changes, + not when the live numbers inside it tick (buffer fill, bitrate, and + 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 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. +- The "delay too big for your buffer" gate is stable instead of jumpy. + It planned from the raw instantaneous bitrate, so a momentary dip + inflated the computed capacity and briefly unlocked a delay the + buffer can't hold - and during that flicker the chip was clickable, + arming a delay that could then never fill. Planning now uses a + slow-decaying peak-hold of the bitrate (worst case), so the gate + can't flicker or transiently unlock an unfillable delay. +- The "Adjusting → rewinding to 15s (currently 0s)…" line told a story + the engine doesn't do - the delay never rewinds gradually; it waits + for enough buffered history, then makes ONE IDR-aligned jump. The + copy now says exactly that ("Building history - jumps to 15s once + … buffered reaches it", then "Jumping back to 15s of delay…"), and + the adjusting detector is gated on a live destination - with none + connected, delivered delay reads 0 and the old line showed a fake + permanent "rewinding" state. + ### Local test sink destination **Try the whole pipeline with zero risk.** The in-binary RTMP sink diff --git a/src/controller.rs b/src/controller.rs index ac577fe..73e0cb8 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -740,8 +740,10 @@ impl Controller { /// /// If a delay is currently *active*, arming a smaller delay /// live-updates the target (no buffer wait needed); arming a larger - /// delay updates the target too, and the controller will smoothly - /// rewind once enough buffer has accumulated. + /// delay updates the target too, and the controller holds position + /// until the ring has enough history, then performs ONE IDR-aligned + /// jump back to it (there is no gradual rewind - see + /// `compute_delay_cut`'s build-buffer-first branch). pub fn arm_delay(&self, ms: u32) { let ms = ms.min(600_000); let previous_target = self.target_delay_ms.load(Ordering::Relaxed); diff --git a/web/index.html b/web/index.html index 5d2aaeb..1a97c80 100644 --- a/web/index.html +++ b/web/index.html @@ -307,9 +307,25 @@ .hero-readout{display:flex;flex-direction:column;gap:14px;padding:28px 32px;min-width:0; border-right:1px solid var(--line);position:relative;overflow:hidden} @media (max-width:880px){.hero-readout{border-right:0;border-bottom:1px solid var(--line)}} +/* Registered custom properties so the aurora COLORS interpolate on a + state change. Without @property, swapping a var() consumed inside a + radial-gradient snaps instantly - gradients aren't transitionable as + background-image, only the registered variable is. Browsers without + @property (old CEF) simply fall back to the old hard swap. */ +@property --aurora-a{syntax:'';inherits:true;initial-value:#5a7a8a} +@property --aurora-b{syntax:'';inherits:true;initial-value:#5a7a8a} +/* The vars live (and transition) on the readout; the ::before/::after + blobs inherit the interpolating value and repaint every frame, so a + state flip reads as one continuous color sweep instead of a cut. */ +.hero-readout{transition:--aurora-a 1.1s var(--ease-out),--aurora-b 1.1s var(--ease-out)} .hero-readout::before,.hero-readout::after{content:'';position:absolute;pointer-events:none; border-radius:50%;filter:blur(60px);opacity:.22;z-index:0; - transition:background .6s var(--ease-out),opacity .6s var(--ease-out)} + transition:opacity .9s var(--ease-out)} +/* The drift animations are deliberately IDENTICAL in every state and + never overridden per-state: changing animation-duration mid-flight + remaps elapsed time to a different progress fraction, teleporting the + blobs on every arming enter/exit (the old behaviour). State "energy" + is conveyed by the separate .aurora-pulse layer instead. */ .hero-readout::before{width:360px;height:360px;left:-80px;top:-80px; background:radial-gradient(circle at 30% 30%,var(--aurora-a,var(--accent)),transparent 70%); animation:aurora-drift-a 14s ease-in-out infinite alternate} @@ -321,11 +337,22 @@ .hero-readout[data-state="armed"] {--aurora-a:var(--accent);--aurora-b:oklch(0.78 0.13 200)} .hero-readout[data-state="active"]{--aurora-a:var(--live);--aurora-b:oklch(0.75 0.16 165)} .hero-readout[data-state="active"]::before,.hero-readout[data-state="active"]::after{opacity:.32} -.hero-readout[data-state="arming"]::before{animation-duration:4s;opacity:.30} -.hero-readout[data-state="arming"]::after{animation-duration:5s;opacity:.30} +.hero-readout[data-state="arming"]::before,.hero-readout[data-state="arming"]::after{opacity:.30} @keyframes aurora-drift-a{0%{transform:translate(0,0) scale(1)} 100%{transform:translate(80px,40px) scale(1.15)}} @keyframes aurora-drift-b{0%{transform:translate(0,0) scale(1.1)} 100%{transform:translate(-60px,-40px) scale(.9)}} .hero-readout>*{position:relative;z-index:1} +/* Breathing layer: the "buffer is filling" heartbeat. Fades in/out via + the opacity transition (smooth entry/exit even though its keyframes + restart), and only animates transform - opacity stays owned by the + transition so the two never fight. Sits under the content like the + blobs; the `.hero-readout>*` z-index lift above is overridden here. */ +.hero-readout>.aurora-pulse{position:absolute;inset:0;z-index:0;pointer-events:none;opacity:0; + background:radial-gradient(70% 90% at 50% 115%,var(--aurora-a,var(--accent)),transparent 72%); + filter:blur(46px);transform-origin:50% 100%; + transition:opacity .9s var(--ease-out)} +.hero-readout[data-state="arming"]>.aurora-pulse{opacity:.26; + animation:aurora-breathe 2.4s ease-in-out infinite} +@keyframes aurora-breathe{0%,100%{transform:scaleY(1)}50%{transform:scaleY(1.18)}} .hero-control{display:flex;flex-direction:column;gap:14px;padding:28px 32px;min-width:0; justify-content:space-between; @@ -335,8 +362,13 @@ .state-pill{display:inline-flex;align-items:center;gap:8px;padding:5px 11px;border-radius:999px; background:color-mix(in oklch,var(--sc,var(--idle)) 8%,var(--surface-3)); border:1px solid color-mix(in oklch,var(--sc,var(--idle)) 30%,transparent); - color:var(--sc,var(--idle));align-self:flex-start} -.state-pill .state-dot{width:7px;height:7px;border-radius:999px;background:var(--sc,var(--idle))} + color:var(--sc,var(--idle));align-self:flex-start; + /* Sweep the pill's colors on a state change instead of snapping - + the consuming properties (not the --sc var itself) interpolate, + shadowing the aurora's 1.1 s color sweep at a snappier pace. */ + transition:color .8s var(--ease-out),background .8s var(--ease-out),border-color .8s var(--ease-out)} +.state-pill .state-dot{width:7px;height:7px;border-radius:999px;background:var(--sc,var(--idle)); + transition:background .8s var(--ease-out)} .state-pill .state-dot.pulse{animation:hpulse 1.6s cubic-bezier(.2,.7,.2,1) infinite} @keyframes hpulse{ 0%{box-shadow:0 0 0 0 color-mix(in oklch,var(--sc) 60%,transparent)} @@ -346,7 +378,10 @@ .hero-delay-row{display:flex;align-items:baseline;gap:4px;line-height:.9;margin-top:-4px} .hero-delay-num{font-size:116px;font-weight:500;letter-spacing:-.045em;color:var(--fg); - font-variant-numeric:tabular-nums;transition:color .25s var(--ease-out)} + /* Color rides the same 1.1 s sweep as the aurora behind it, so the + number and the glow change hue together instead of the number + snapping a second ahead of the background. */ + font-variant-numeric:tabular-nums;transition:color 1.1s var(--ease-out)} .hero-delay-num.d-off {color:var(--fg-3)} .hero-delay-num.d-arming{color:var(--warn)} .hero-delay-num.d-armed {color:var(--accent)} @@ -354,13 +389,22 @@ .hero-delay-unit{font-size:32px;color:var(--fg-3);font-weight:400} .hero-delay-of{font-size:22px;color:var(--fg-3);margin-left:6px} .hero-delay-sub{color:var(--fg-3);font-size:13px;min-height:1.4em; - transition:opacity .15s var(--ease-out),transform .15s var(--ease-out)} -.hero-delay-sub.subfade{opacity:0;transform:translateY(2px)} + transition:opacity .22s var(--ease-out),transform .22s var(--ease-out)} +/* Message swap: old text slides down + fades, new text drops in from + above. `.subenter` is the pre-swap frame (parked above, invisible, + transitions off so the jump is instant); removing it lands the new + text with the normal transition. Declared after .subfade so its + transition:none wins while both are present mid-swap. */ +.hero-delay-sub.subfade{opacity:0;transform:translateY(4px)} +.hero-delay-sub.subenter{transition:none;opacity:0;transform:translateY(-4px)} .arm-bar{height:4px;width:100%;background:var(--surface-3);border-radius:999px;overflow:hidden; margin-top:-4px;position:relative} .arm-bar-fill{height:100%;background:linear-gradient(90deg,color-mix(in oklch,var(--warn) 60%,transparent),var(--warn)); border-radius:999px;box-shadow:0 0 12px color-mix(in oklch,var(--warn) 50%,transparent); - transition:width .3s var(--ease-out)} + /* Linear, sized to the ~250 ms SSE cadence: each update hands off to + the next at constant speed, so the bar fills continuously instead + of easing-out into a sawtooth 4x a second. */ + transition:width .35s linear} .arm-bar::after{content:'';position:absolute;inset:0; background:linear-gradient(90deg,transparent 0%,rgba(255,255,255,.15) 50%,transparent 100%); animation:arm-shimmer 1.6s linear infinite;pointer-events:none} @@ -1428,6 +1472,9 @@

Welcome to InstantClone