diff --git a/.agents/skills/leptos-ui-development/SKILL.md b/.agents/skills/leptos-ui-development/SKILL.md index 8aff89781..cf8d75335 100644 --- a/.agents/skills/leptos-ui-development/SKILL.md +++ b/.agents/skills/leptos-ui-development/SKILL.md @@ -348,7 +348,10 @@ Token files in `tokens/` (DESIGN-SYSTEM.md §3 has the full architecture): **document element**, not the shell div. Custom-property `var()` substitution resolves where the property is *declared*, and every `--ambient-*` token is declared in a `:root` block of `semantic.css`, so a hue set lower in the tree -would never reach them. +would never reach them. That write restyles the whole document, so +`AmbientHuePublisher` (`src/ambient.rs`) paces it to at most once a second +with a 4° deadband, and `.preview-glow` drifts between published values on +the non-inherited `--ambient-hue-drift` property. **Theme**: stored in localStorage as `hc-theme` and applied by an inline `index.html` script _before first paint_ to prevent flash. There is no runtime @@ -407,12 +410,11 @@ while the consumer count is above zero. Parallel counters exist for the other streams (`set_screen_preview_consumers`, `set_screen_zones_consumers`, `set_web_viewport_preview_consumers`, `set_device_metrics_consumers`). -FPS caps (`src/ws/preview.rs:11-14`): +FPS caps (`src/ws/preview.rs`): | Constant | Value | Applies to | | ------------------------------- | ----- | ---------------------------------- | | `DEFAULT_PREVIEW_FPS_CAP` | 60 | The main canvas preview | -| `HIDDEN_TAB_PREVIEW_FPS_CAP` | 6 | Any preview while the tab is hidden | | `SCREEN_PREVIEW_FPS_CAP` | 15 | The screen-source preview | | `WEB_VIEWPORT_PREVIEW_FPS_CAP` | 15 | The web-viewport preview | @@ -431,9 +433,17 @@ Canvas preview does **not** use `Portal`. Portal is used by `modal.rs`, `control_panel/{color,enum_select,sensor}.rs` popovers, which have to escape overflow-hidden cards. -## Visibility-Aware FPS - -Tab hidden → reduce preview FPS to 6 via `document.visibilitychange` listener. Resets smoothed FPS counters on reconnect to avoid glitch display. +## Hidden Means Idle + +A hidden page shows no frame, so `should_stream_preview` subscribes to none: +when `document.visibilitychange` reports hidden, or the Tauri window reports +hidden (`hypercolor-window-visibility`), the canvas, screen-canvas, +web-viewport, screen-zones and display-preview subscriptions are dropped and +the daemon stops encoding for this client. Becoming visible resubscribes from +scratch through the same path a reconnect takes, so no epoch is lost. An +interactive preview closes with the canvas stream (its extent comes from the +authoritative frame) and reopens when frames return. Smoothed FPS counters +reset on reconnect to avoid a glitchy display. ## leptos_icons Gotcha diff --git a/.agents/skills/leptos-ui-development/references/websocket-protocol.md b/.agents/skills/leptos-ui-development/references/websocket-protocol.md index 5f6d0b5f1..2799d49cf 100644 --- a/.agents/skills/leptos-ui-development/references/websocket-protocol.md +++ b/.agents/skills/leptos-ui-development/references/websocket-protocol.md @@ -42,6 +42,15 @@ code. Decode them through `PreviewBinaryDecoder`, which uses the canonical `hypercolor-leptos-ext::ws` codecs and negotiates the daemon's advertised transport capability. +A frame larger than one message (a 640x480 RGBA canvas is) arrives as +`0x0f` chunks. The decoder reassembles them with +`PreviewChunkReassembler`: only each chunk's +envelope is copied into WASM, the payloads are copied once into a JS +`Uint8Array`, and the assembled buffer goes through the same +`*View::decode_array_buffer` decoders as a frame that arrived whole, so the +preview uploads straight from a view of it. Never route frame payloads +through `Vec` in the UI; each crossing is a full-frame copy. + The generated byte-level contract lives in [`docs/content/api/websocket-binary-frames.md`](../../../../docs/content/api/websocket-binary-frames.md). diff --git a/crates/hypercolor-leptos-ext/src/raf/mod.rs b/crates/hypercolor-leptos-ext/src/raf/mod.rs index 151da49ba..89e51f7f9 100644 --- a/crates/hypercolor-leptos-ext/src/raf/mod.rs +++ b/crates/hypercolor-leptos-ext/src/raf/mod.rs @@ -15,7 +15,11 @@ pub struct Scheduler { struct SchedulerInner { window: web_sys::Window, - callback: RefCell>>, + /// One closure serves every frame this scheduler requests. A fresh + /// `Closure` per frame would allocate a JS function and register it with + /// wasm-bindgen's finalization registry sixty times a second, churn the + /// garbage collector pays for on every animated surface. + callback: Closure, pending_frame: Cell>, active: Cell, user: RefCell>, @@ -46,14 +50,21 @@ impl Scheduler { { let window = web_sys::window().expect("browser window is available for animation frames"); Self { - inner: Rc::new(SchedulerInner { - window, - callback: RefCell::new(None), - pending_frame: Cell::new(None), - active: Cell::new(false), - user: RefCell::new(Box::new(callback)), - last_time_ms: Cell::new(None), - mode: Cell::new(Mode::Idle), + inner: Rc::new_cyclic(|weak: &Weak| { + let weak = Weak::clone(weak); + SchedulerInner { + window, + callback: Closure::::new(move |time_ms| { + if let Some(inner) = Weak::upgrade(&weak) { + on_animation_frame(&inner, time_ms); + } + }), + pending_frame: Cell::new(None), + active: Cell::new(false), + user: RefCell::new(Box::new(callback)), + last_time_ms: Cell::new(None), + mode: Cell::new(Mode::Idle), + } }), } } @@ -96,7 +107,6 @@ impl SchedulerInner { if let Some(request_id) = self.pending_frame.take() { let _ = self.window.cancel_animation_frame(request_id); } - self.callback.borrow_mut().take(); } fn monotonic_ms(&self, time_ms: f64) -> f64 { @@ -108,10 +118,11 @@ impl SchedulerInner { impl Drop for SchedulerInner { fn drop(&mut self) { + // Cancel before the closure drops with the struct, so the browser + // never calls into a freed callback. if let Some(request_id) = self.pending_frame.take() { let _ = self.window.cancel_animation_frame(request_id); } - self.callback.borrow_mut().take(); } } @@ -120,19 +131,11 @@ fn request_next_frame(inner: &Rc) { return; } - let weak = Rc::downgrade(inner); - let callback = Closure::::new(move |time_ms| { - if let Some(inner) = Weak::upgrade(&weak) { - on_animation_frame(&inner, time_ms); - } - }); - if let Ok(request_id) = inner .window - .request_animation_frame(callback.as_ref().unchecked_ref()) + .request_animation_frame(inner.callback.as_ref().unchecked_ref()) { inner.pending_frame.set(Some(request_id)); - inner.callback.borrow_mut().replace(callback); } } @@ -140,7 +143,6 @@ fn on_animation_frame(inner: &Rc, time_ms: f64) { inner.pending_frame.take(); if !inner.active.get() { - inner.callback.borrow_mut().take(); return; } @@ -164,8 +166,6 @@ fn on_animation_frame(inner: &Rc, time_ms: f64) { inner.mode.set(Mode::Idle); } - inner.callback.borrow_mut().take(); - if keep_running { request_next_frame(inner); } diff --git a/crates/hypercolor-leptos-ext/src/ws/mod.rs b/crates/hypercolor-leptos-ext/src/ws/mod.rs index c1b9aadbc..8748f4f47 100644 --- a/crates/hypercolor-leptos-ext/src/ws/mod.rs +++ b/crates/hypercolor-leptos-ext/src/ws/mod.rs @@ -35,17 +35,20 @@ pub use preview::{ PREVIEW_CANCEL_FIXED_HEADER_LEN, PREVIEW_CANCEL_FRAME_TAG, PREVIEW_CANCEL_SCHEMA, PREVIEW_CHUNK_FIXED_HEADER_LEN, PREVIEW_CHUNK_FRAME_TAG, PREVIEW_CHUNK_SCHEMA, PREVIEW_FRAME_HEADER_LEN, PREVIEW_MIN_MESSAGE_BYTES, PreviewCancelFrame, PreviewChunkError, - PreviewChunkFrame, PreviewChunkReassembler, PreviewFrame, PreviewFrameChannel, - PreviewFrameDecodeError, PreviewPixelFormat, PreviewPublicationMetadata, - PreviewReassemblyLimits, PreviewStreamId, PreviewTransportLimits, - ReassembledPreviewPublication, SCREEN_ZONES_FRAME_HEADER_LEN, SCREEN_ZONES_FRAME_TAG, - ScreenZonesFrame, WIDE_DISPLAY_PREVIEW_FRAME_PREFIX_LEN, WIDE_DISPLAY_PREVIEW_FRAME_TAG, - WIDE_INTERACTIVE_PREVIEW_FRAME_PREFIX_LEN, WIDE_INTERACTIVE_PREVIEW_FRAME_TAG, - WIDE_PREVIEW_FRAME_HEADER_LEN, WIDE_PREVIEW_FRAME_TAG, WIDE_SCREEN_ZONES_FRAME_HEADER_LEN, - WIDE_SCREEN_ZONES_FRAME_TAG, WIDE_ZONE_PREVIEW_FRAME_HEADER_LEN, WIDE_ZONE_PREVIEW_FRAME_TAG, - ZONE_PREVIEW_FRAME_HEADER_LEN, ZONE_PREVIEW_FRAME_TAG, ZonePreviewFrame, - split_preview_publication, split_preview_publication_with_limits, + PreviewChunkFrame, PreviewChunkHeader, PreviewChunkPayload, PreviewChunkReassembler, + PreviewFrame, PreviewFrameChannel, PreviewFrameDecodeError, PreviewPixelFormat, + PreviewPublicationBuffer, PreviewPublicationMetadata, PreviewReassemblyLimits, PreviewStreamId, + PreviewTransportLimits, ReassembledPreviewPublication, SCREEN_ZONES_FRAME_HEADER_LEN, + SCREEN_ZONES_FRAME_TAG, ScreenZonesFrame, WIDE_DISPLAY_PREVIEW_FRAME_PREFIX_LEN, + WIDE_DISPLAY_PREVIEW_FRAME_TAG, WIDE_INTERACTIVE_PREVIEW_FRAME_PREFIX_LEN, + WIDE_INTERACTIVE_PREVIEW_FRAME_TAG, WIDE_PREVIEW_FRAME_HEADER_LEN, WIDE_PREVIEW_FRAME_TAG, + WIDE_SCREEN_ZONES_FRAME_HEADER_LEN, WIDE_SCREEN_ZONES_FRAME_TAG, + WIDE_ZONE_PREVIEW_FRAME_HEADER_LEN, WIDE_ZONE_PREVIEW_FRAME_TAG, ZONE_PREVIEW_FRAME_HEADER_LEN, + ZONE_PREVIEW_FRAME_TAG, ZonePreviewFrame, split_preview_publication, + split_preview_publication_with_limits, }; +#[cfg(feature = "ws-client-wasm")] +pub use preview::{JsPreviewPublicationBuffer, ReassembledPreviewBuffer}; pub use spectrum::{ SPECTRUM_FRAME_HEADER_LEN, SPECTRUM_FRAME_TAG, SpectrumFrame, SpectrumFrameDecodeError, }; diff --git a/crates/hypercolor-leptos-ext/src/ws/preview.rs b/crates/hypercolor-leptos-ext/src/ws/preview.rs index 9deefdc88..095b88271 100644 --- a/crates/hypercolor-leptos-ext/src/ws/preview.rs +++ b/crates/hypercolor-leptos-ext/src/ws/preview.rs @@ -886,10 +886,57 @@ impl PreviewChunkFrame { } pub fn decode_bytes(input: &Bytes) -> Result { - if input.len() < PREVIEW_CHUNK_FIXED_HEADER_LEN { + let (header, payload_offset) = PreviewChunkHeader::decode_prefix(input, input.len())?; + Ok(Self { + metadata: header.metadata, + total_encoded_bytes: header.total_encoded_bytes, + chunk_offset: header.chunk_offset, + chunk_index: header.chunk_index, + chunk_count: header.chunk_count, + payload: input.slice(payload_offset..), + }) + } + + fn validate_layout(&self) -> Result<(), PreviewChunkError> { + validate_chunk_layout( + self.total_encoded_bytes, + self.chunk_offset, + self.chunk_index, + self.chunk_count, + self.payload.len(), + ) + } +} + +/// A chunk envelope without its payload: where the payload belongs in its +/// publication. Decoding needs only the envelope's leading bytes, so a +/// client can place a payload it never copies. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreviewChunkHeader { + pub metadata: PreviewPublicationMetadata, + pub total_encoded_bytes: u64, + pub chunk_offset: u64, + pub chunk_index: u32, + pub chunk_count: u32, +} + +impl PreviewChunkHeader { + /// Decode the envelope of a chunk message `message_len` bytes long. + /// + /// `input` starts at the message's first byte and holds at least the + /// envelope (the fixed header plus the stream identity); it may hold the + /// whole message. The payload length follows from `message_len` and is + /// checked against the layout. Returns the header and the payload offset. + pub fn decode_prefix( + input: &[u8], + message_len: usize, + ) -> Result<(Self, usize), PreviewChunkError> { + if message_len < PREVIEW_CHUNK_FIXED_HEADER_LEN + || input.len() < PREVIEW_CHUNK_FIXED_HEADER_LEN + { return Err(PreviewChunkError::TooShort { expected: PREVIEW_CHUNK_FIXED_HEADER_LEN, - actual: input.len(), + actual: message_len.min(input.len()), }); } if input[0] != PREVIEW_CHUNK_FRAME_TAG { @@ -904,10 +951,10 @@ impl PreviewChunkFrame { let payload_offset = PREVIEW_CHUNK_FIXED_HEADER_LEN .checked_add(identity_len) .ok_or(PreviewChunkError::LengthOverflow)?; - if input.len() < payload_offset { + if message_len < payload_offset || input.len() < payload_offset { return Err(PreviewChunkError::TooShort { expected: payload_offset, - actual: input.len(), + actual: message_len.min(input.len()), }); } let stream = PreviewStreamId::decode( @@ -915,7 +962,7 @@ impl PreviewChunkFrame { input[3], &input[PREVIEW_CHUNK_FIXED_HEADER_LEN..payload_offset], )?; - let frame = Self { + let header = Self { metadata: PreviewPublicationMetadata { stream, publication_id: u64::from_le_bytes( @@ -937,43 +984,52 @@ impl PreviewChunkFrame { chunk_offset: u64::from_le_bytes(input[39..47].try_into().expect("slice has 8 bytes")), chunk_index: u32::from_le_bytes(input[47..51].try_into().expect("slice has 4 bytes")), chunk_count: u32::from_le_bytes(input[51..55].try_into().expect("slice has 4 bytes")), - payload: input.slice(payload_offset..), }; - frame.validate_layout()?; - Ok(frame) + validate_chunk_layout( + header.total_encoded_bytes, + header.chunk_offset, + header.chunk_index, + header.chunk_count, + message_len - payload_offset, + )?; + Ok((header, payload_offset)) } +} - fn validate_layout(&self) -> Result<(), PreviewChunkError> { - if self.total_encoded_bytes == 0 || self.chunk_count == 0 { - return Err(PreviewChunkError::InvalidLayout); - } - if u64::from(self.chunk_count) > self.total_encoded_bytes { - return Err(PreviewChunkError::ImpossibleChunkCount { - chunks: self.chunk_count, - total_bytes: self.total_encoded_bytes, - }); - } - if self.chunk_index >= self.chunk_count || self.payload.is_empty() { - return Err(PreviewChunkError::InvalidLayout); - } - let payload_len = - u64::try_from(self.payload.len()).map_err(|_| PreviewChunkError::LengthOverflow)?; - let end = self - .chunk_offset - .checked_add(payload_len) - .ok_or(PreviewChunkError::LengthOverflow)?; - if end > self.total_encoded_bytes { - return Err(PreviewChunkError::InvalidLayout); - } - if self.chunk_index + 1 == self.chunk_count { - if end != self.total_encoded_bytes { - return Err(PreviewChunkError::InvalidLayout); - } - } else if end >= self.total_encoded_bytes { +fn validate_chunk_layout( + total_encoded_bytes: u64, + chunk_offset: u64, + chunk_index: u32, + chunk_count: u32, + payload_len: usize, +) -> Result<(), PreviewChunkError> { + if total_encoded_bytes == 0 || chunk_count == 0 { + return Err(PreviewChunkError::InvalidLayout); + } + if u64::from(chunk_count) > total_encoded_bytes { + return Err(PreviewChunkError::ImpossibleChunkCount { + chunks: chunk_count, + total_bytes: total_encoded_bytes, + }); + } + if chunk_index >= chunk_count || payload_len == 0 { + return Err(PreviewChunkError::InvalidLayout); + } + let payload_len = u64::try_from(payload_len).map_err(|_| PreviewChunkError::LengthOverflow)?; + let end = chunk_offset + .checked_add(payload_len) + .ok_or(PreviewChunkError::LengthOverflow)?; + if end > total_encoded_bytes { + return Err(PreviewChunkError::InvalidLayout); + } + if chunk_index + 1 == chunk_count { + if end != total_encoded_bytes { return Err(PreviewChunkError::InvalidLayout); } - Ok(()) + } else if end >= total_encoded_bytes { + return Err(PreviewChunkError::InvalidLayout); } + Ok(()) } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1212,28 +1268,104 @@ pub struct ReassembledPreviewPublication { pub encoded: Bytes, } +/// One chunk's payload bytes, wherever they live. +pub trait PreviewChunkPayload { + fn byte_len(&self) -> usize; + + /// Copy the payload's leading bytes into `out`, as many as fit or as + /// the payload holds. Returns the count copied. + fn copy_prefix_into(&self, out: &mut [u8]) -> usize; +} + +/// Storage a reassembler fills with one publication's encoded bytes. +/// +/// The state machine, budgets and tombstones are the same for every +/// storage; only where the bytes accumulate differs. `Vec` serves +/// native clients, and the browser keeps payloads in JS so a chunked frame +/// never crosses into WASM memory. +pub trait PreviewPublicationBuffer: Sized { + type Payload: PreviewChunkPayload; + + /// Empty storage with room for `capacity` bytes, or `None` when that + /// much cannot be allocated. + fn try_with_capacity(capacity: usize) -> Option; + + fn byte_len(&self) -> usize; + + /// Make room for `total` bytes in all. `false` when allocation fails. + fn try_reserve_total(&mut self, total: usize) -> bool; + + /// Append a payload. `false` when growing the storage fails. + fn try_append(&mut self, payload: &Self::Payload) -> bool; + + /// Copy the stored leading bytes into `out`. Returns the count copied. + fn copy_prefix_into(&self, out: &mut [u8]) -> usize; +} + +impl PreviewChunkPayload for Bytes { + fn byte_len(&self) -> usize { + self.len() + } + + fn copy_prefix_into(&self, out: &mut [u8]) -> usize { + let count = out.len().min(self.len()); + out[..count].copy_from_slice(&self[..count]); + count + } +} + +impl PreviewPublicationBuffer for Vec { + type Payload = Bytes; + + fn try_with_capacity(capacity: usize) -> Option { + let mut bytes = Vec::new(); + bytes.try_reserve_exact(capacity).ok()?; + Some(bytes) + } + + fn byte_len(&self) -> usize { + self.len() + } + + fn try_reserve_total(&mut self, total: usize) -> bool { + self.try_reserve_exact(total.saturating_sub(self.len())) + .is_ok() + } + + fn try_append(&mut self, payload: &Bytes) -> bool { + self.extend_from_slice(payload); + true + } + + fn copy_prefix_into(&self, out: &mut [u8]) -> usize { + let count = out.len().min(self.len()); + out[..count].copy_from_slice(&self[..count]); + count + } +} + #[derive(Debug)] -struct PartialPreviewPublication { +struct PartialPreviewPublication { metadata: PreviewPublicationMetadata, total_encoded_bytes: usize, chunk_count: u32, next_chunk_index: u32, screen_zones_header_validated: bool, - bytes: Vec, + bytes: B, } #[derive(Debug)] -struct PreviewStreamState { +struct PreviewStreamState { high_water_publication_id: u64, - partial: Option, + partial: Option>, last_activity_ms: u64, tombstone_generation: u64, } #[derive(Debug)] -pub struct PreviewChunkReassembler { +pub struct PreviewChunkReassembler> { limits: PreviewReassemblyLimits, - streams: HashMap, PreviewStreamState>, + streams: HashMap, PreviewStreamState>, active_streams: usize, tombstones: usize, reserved_bytes: usize, @@ -1248,19 +1380,7 @@ pub struct PreviewChunkReassembler { impl PreviewChunkReassembler { #[must_use] pub fn new(limits: PreviewReassemblyLimits) -> Self { - Self { - limits, - streams: HashMap::new(), - active_streams: 0, - tombstones: 0, - reserved_bytes: 0, - state_bytes: 0, - tombstone_bytes: 0, - logical_now_ms: 0, - next_tombstone_generation: 1, - superseded_publications: 0, - expired_publications: 0, - } + Self::with_limits(limits) } pub fn push( @@ -1276,15 +1396,61 @@ impl PreviewChunkReassembler { encoded_chunk: &Bytes, now_ms: u64, ) -> Result, PreviewChunkError> { + self.begin_push(encoded_chunk.len(), now_ms)?; + let (header, payload_offset) = + PreviewChunkHeader::decode_prefix(encoded_chunk, encoded_chunk.len())?; + let payload = encoded_chunk.slice(payload_offset..); + let Some((metadata, bytes)) = self.push_parts(&header, &payload, now_ms)? else { + return Ok(None); + }; + let encoded = Bytes::from(bytes); + validate_completed_publication(&metadata, &encoded, self.limits)?; + Ok(Some(ReassembledPreviewPublication { metadata, encoded })) + } +} + +impl PreviewChunkReassembler { + /// A reassembler that accumulates publications in `B`. + #[must_use] + pub fn with_limits(limits: PreviewReassemblyLimits) -> Self { + Self { + limits, + streams: HashMap::new(), + active_streams: 0, + tombstones: 0, + reserved_bytes: 0, + state_bytes: 0, + tombstone_bytes: 0, + logical_now_ms: 0, + next_tombstone_generation: 1, + superseded_publications: 0, + expired_publications: 0, + } + } + + /// Advance the clock, sweep idle partials, and check a chunk message's + /// length against the budget, before anything decodes it. + fn begin_push(&mut self, message_len: usize, now_ms: u64) -> Result<(), PreviewChunkError> { self.logical_now_ms = self.logical_now_ms.max(now_ms); self.expire_at(now_ms); - if encoded_chunk.len() > self.limits.max_message_bytes { + if message_len > self.limits.max_message_bytes { return Err(PreviewChunkError::MessageBudgetExceeded { - requested: encoded_chunk.len(), + requested: message_len, limit: self.limits.max_message_bytes, }); } - let chunk = PreviewChunkFrame::decode_bytes(encoded_chunk)?; + Ok(()) + } + + /// Place one decoded chunk. Returns the publication's metadata and + /// storage once its last chunk lands; validating the assembled bytes is + /// the caller's, since only it knows how to read its storage. + fn push_parts( + &mut self, + chunk: &PreviewChunkHeader, + payload: &B::Payload, + now_ms: u64, + ) -> Result, PreviewChunkError> { let max_chunk_count = self .limits .effective_max_chunk_count(chunk.metadata.stream.identity_bytes()); @@ -1320,14 +1486,15 @@ impl PreviewChunkReassembler { .get(&stream) .is_none_or(|state| publication_id > state.high_water_publication_id); if starts_new_publication { - self.admit_partial(&chunk, total_encoded_bytes, now_ms)?; + self.admit_partial(chunk, payload, total_encoded_bytes, now_ms)?; } - self.append_chunk(&stream, &chunk, total_encoded_bytes, now_ms) + self.append_chunk(&stream, chunk, payload, total_encoded_bytes, now_ms) } fn admit_partial( &mut self, - chunk: &PreviewChunkFrame, + chunk: &PreviewChunkHeader, + payload: &B::Payload, total_encoded_bytes: usize, now_ms: u64, ) -> Result<(), PreviewChunkError> { @@ -1335,7 +1502,7 @@ impl PreviewChunkReassembler { return Err(PreviewChunkError::MissingPublicationStart); } let screen_zones_header_validated = - validate_reassembly_admission(chunk, total_encoded_bytes, self.limits)?; + validate_reassembly_admission(chunk, payload, total_encoded_bytes, self.limits)?; let stream = &chunk.metadata.stream; let existing_is_active = self .streams @@ -1391,8 +1558,7 @@ impl PreviewChunkReassembler { } drop(replaced); - let mut bytes = Vec::new(); - if bytes.try_reserve_exact(initial_reservation).is_err() { + let Some(bytes) = B::try_with_capacity(initial_reservation) else { if existing_is_active { self.active_streams = self.active_streams.saturating_sub(1); self.mark_tombstone(stream, true); @@ -1400,7 +1566,7 @@ impl PreviewChunkReassembler { return Err(PreviewChunkError::AllocationFailed { bytes: initial_reservation, }); - } + }; let partial = PartialPreviewPublication { metadata: chunk.metadata.clone(), total_encoded_bytes, @@ -1445,10 +1611,11 @@ impl PreviewChunkReassembler { fn append_chunk( &mut self, stream: &PreviewStreamId, - chunk: &PreviewChunkFrame, + chunk: &PreviewChunkHeader, + payload: &B::Payload, total_encoded_bytes: usize, now_ms: u64, - ) -> Result, PreviewChunkError> { + ) -> Result, PreviewChunkError> { let state = self .streams .get_mut(stream) @@ -1466,43 +1633,46 @@ impl PreviewChunkReassembler { if chunk.chunk_index < partial.next_chunk_index { return Err(PreviewChunkError::DuplicateChunk); } - let expected_offset = - u64::try_from(partial.bytes.len()).map_err(|_| PreviewChunkError::LengthOverflow)?; + let expected_offset = u64::try_from(partial.bytes.byte_len()) + .map_err(|_| PreviewChunkError::LengthOverflow)?; if chunk.chunk_index != partial.next_chunk_index || chunk.chunk_offset != expected_offset { return Err(PreviewChunkError::NonContiguousChunk); } if !partial.screen_zones_header_validated { let prefix_len = partial .bytes - .len() - .checked_add(chunk.payload.len()) + .byte_len() + .checked_add(payload.byte_len()) .ok_or(PreviewChunkError::LengthOverflow)? .min(EXTENDED_SCREEN_ZONES_FRAME_HEADER_LEN); let mut prefix = [0_u8; EXTENDED_SCREEN_ZONES_FRAME_HEADER_LEN]; - let retained_prefix_len = partial.bytes.len().min(prefix_len); - prefix[..retained_prefix_len].copy_from_slice(&partial.bytes[..retained_prefix_len]); - let incoming_prefix_len = prefix_len.saturating_sub(retained_prefix_len); - prefix[retained_prefix_len..prefix_len] - .copy_from_slice(&chunk.payload[..incoming_prefix_len]); + let retained_prefix_len = partial.bytes.byte_len().min(prefix_len); + partial + .bytes + .copy_prefix_into(&mut prefix[..retained_prefix_len]); + payload.copy_prefix_into(&mut prefix[retained_prefix_len..prefix_len]); if validate_screen_zones_header_prefix( &partial.metadata, partial.total_encoded_bytes, &prefix[..prefix_len], self.limits, )? { - let additional = partial - .total_encoded_bytes - .checked_sub(partial.bytes.len()) - .ok_or(PreviewChunkError::LengthOverflow)?; - partial.bytes.try_reserve_exact(additional).map_err(|_| { - PreviewChunkError::AllocationFailed { + if partial.bytes.byte_len() > partial.total_encoded_bytes { + return Err(PreviewChunkError::LengthOverflow); + } + if !partial.bytes.try_reserve_total(partial.total_encoded_bytes) { + return Err(PreviewChunkError::AllocationFailed { bytes: partial.total_encoded_bytes, - } - })?; + }); + } partial.screen_zones_header_validated = true; } } - partial.bytes.extend_from_slice(&chunk.payload); + if !partial.bytes.try_append(payload) { + return Err(PreviewChunkError::AllocationFailed { + bytes: partial.total_encoded_bytes, + }); + } partial.next_chunk_index = partial .next_chunk_index .checked_add(1) @@ -1512,7 +1682,7 @@ impl PreviewChunkReassembler { if partial.next_chunk_index != partial.chunk_count { return Ok(None); } - if partial.bytes.len() != partial.total_encoded_bytes { + if partial.bytes.byte_len() != partial.total_encoded_bytes { return Err(PreviewChunkError::NonContiguousChunk); } let completed = state @@ -1525,12 +1695,7 @@ impl PreviewChunkReassembler { .ok_or(PreviewChunkError::LengthOverflow)?; self.active_streams = self.active_streams.saturating_sub(1); self.mark_tombstone(stream, true); - let encoded = Bytes::from(completed.bytes); - validate_completed_publication(&completed.metadata, &encoded, self.limits)?; - Ok(Some(ReassembledPreviewPublication { - metadata: completed.metadata, - encoded, - })) + Ok(Some((completed.metadata, completed.bytes))) } pub fn expire_at(&mut self, now_ms: u64) -> usize { @@ -1766,16 +1931,20 @@ impl PreviewChunkReassembler { } } +/// Budgeted size of one stream's state. Measured with the native storage +/// for every buffer type, so a browser client admits exactly the streams +/// a native one does. fn preview_stream_state_bytes(stream: &PreviewStreamId) -> Result { - std::mem::size_of::<(Arc, PreviewStreamState)>() + std::mem::size_of::<(Arc, PreviewStreamState>)>() .checked_add(std::mem::size_of::<(usize, usize)>()) .and_then(|bytes| bytes.checked_add(std::mem::size_of::())) .and_then(|bytes| bytes.checked_add(stream.identity_bytes())) .ok_or(PreviewChunkError::LengthOverflow) } -fn validate_reassembly_admission( - chunk: &PreviewChunkFrame, +fn validate_reassembly_admission( + chunk: &PreviewChunkHeader, + payload: &P, total_encoded_bytes: usize, limits: PreviewReassemblyLimits, ) -> Result { @@ -1799,10 +1968,12 @@ fn validate_reassembly_admission( limit: limits.max_decoded_publication_bytes, }); } + let mut prefix = [0_u8; EXTENDED_SCREEN_ZONES_FRAME_HEADER_LEN]; + let prefix_len = payload.copy_prefix_into(&mut prefix); return validate_screen_zones_header_prefix( metadata, total_encoded_bytes, - &chunk.payload, + &prefix[..prefix_len], limits, ); } @@ -2480,6 +2651,171 @@ impl IdentityFrameHeader { } } +#[cfg(feature = "ws-client-wasm")] +impl PreviewChunkPayload for js_sys::Uint8Array { + fn byte_len(&self) -> usize { + self.length() as usize + } + + fn copy_prefix_into(&self, out: &mut [u8]) -> usize { + let count = out.len().min(self.length() as usize); + self.subarray(0, count as u32).copy_to(&mut out[..count]); + count + } +} + +/// Browser storage for chunked publications: a JS `Uint8Array` that chunk +/// payloads are copied into straight from their WebSocket buffers. +/// +/// A publication assembled here costs one JS-to-JS copy and one allocation, +/// the same as a frame small enough to arrive in a single message, instead +/// of a copy into WASM memory, a second into the reassembly buffer, and a +/// third back out to JS. +#[cfg(feature = "ws-client-wasm")] +#[derive(Debug)] +pub struct JsPreviewPublicationBuffer { + bytes: js_sys::Uint8Array, + len: usize, +} + +#[cfg(feature = "ws-client-wasm")] +impl JsPreviewPublicationBuffer { + /// Allocate through `Reflect.construct` so the `RangeError` an + /// oversized buffer throws comes back as `None`. A plain constructor + /// call would throw straight through the WASM frames above it. + fn try_allocate(capacity: usize) -> Option { + use wasm_bindgen::JsCast; + + let length = u32::try_from(capacity).ok()?; + let constructor = js_sys::Reflect::get( + &js_sys::global(), + &wasm_bindgen::JsValue::from_str("Uint8Array"), + ) + .ok()? + .dyn_into::() + .ok()?; + js_sys::Reflect::construct( + &constructor, + &js_sys::Array::of1(&wasm_bindgen::JsValue::from(length)), + ) + .ok()? + .dyn_into::() + .ok() + } + + fn grow_to(&mut self, capacity: usize) -> bool { + let Some(grown) = Self::try_allocate(capacity) else { + return false; + }; + grown.set(&self.bytes.subarray(0, self.len as u32), 0); + self.bytes = grown; + true + } + + /// The assembled publication in an `ArrayBuffer` of its own, ready for + /// the same decoders a single-message frame goes through. + #[must_use] + pub fn into_array_buffer(self) -> js_sys::ArrayBuffer { + if self.bytes.length() as usize == self.len { + self.bytes.buffer() + } else { + self.bytes.slice(0, self.len as u32).buffer() + } + } +} + +#[cfg(feature = "ws-client-wasm")] +impl PreviewPublicationBuffer for JsPreviewPublicationBuffer { + type Payload = js_sys::Uint8Array; + + fn try_with_capacity(capacity: usize) -> Option { + Some(Self { + bytes: Self::try_allocate(capacity)?, + len: 0, + }) + } + + fn byte_len(&self) -> usize { + self.len + } + + fn try_reserve_total(&mut self, total: usize) -> bool { + self.bytes.length() as usize >= total || self.grow_to(total) + } + + fn try_append(&mut self, payload: &js_sys::Uint8Array) -> bool { + let Some(end) = self.len.checked_add(payload.length() as usize) else { + return false; + }; + let capacity = self.bytes.length() as usize; + if end > capacity && !self.grow_to(end.max(capacity.saturating_mul(2))) { + return false; + } + let Ok(offset) = u32::try_from(self.len) else { + return false; + }; + self.bytes.set(payload, offset); + self.len = end; + true + } + + fn copy_prefix_into(&self, out: &mut [u8]) -> usize { + let count = out.len().min(self.len); + self.bytes + .subarray(0, count as u32) + .copy_to(&mut out[..count]); + count + } +} + +/// A publication reassembled in JS: its metadata, and its encoded bytes +/// exactly as a single-message frame would carry them. +#[cfg(feature = "ws-client-wasm")] +#[derive(Debug, Clone)] +pub struct ReassembledPreviewBuffer { + pub metadata: PreviewPublicationMetadata, + pub encoded: js_sys::ArrayBuffer, +} + +#[cfg(feature = "ws-client-wasm")] +impl PreviewChunkReassembler { + /// Place one chunk message straight from its WebSocket buffer. Only the + /// envelope is copied into WASM memory; the payload stays in JS. + /// + /// Unlike [`PreviewChunkReassembler::push_at`], a completed publication + /// is not decoded here. The caller decodes [`ReassembledPreviewBuffer::encoded`] + /// with the `*View::decode_array_buffer` decoders and checks the result + /// against the metadata, as it would a frame that arrived whole. + pub fn push_array_buffer_at( + &mut self, + message: &js_sys::ArrayBuffer, + now_ms: u64, + ) -> Result, PreviewChunkError> { + let data = js_sys::Uint8Array::new(message); + let message_len = data.length() as usize; + self.begin_push(message_len, now_ms)?; + let fixed_len = message_len.min(PREVIEW_CHUNK_FIXED_HEADER_LEN); + let mut envelope = data.subarray(0, fixed_len as u32).to_vec(); + if envelope.len() == PREVIEW_CHUNK_FIXED_HEADER_LEN { + let identity_len = usize::from(u16::from_le_bytes([envelope[5], envelope[6]])); + let envelope_len = PREVIEW_CHUNK_FIXED_HEADER_LEN + .saturating_add(identity_len) + .min(message_len); + if envelope_len > envelope.len() { + envelope = data.subarray(0, envelope_len as u32).to_vec(); + } + } + let (header, payload_offset) = PreviewChunkHeader::decode_prefix(&envelope, message_len)?; + let payload = data.subarray(payload_offset as u32, message_len as u32); + Ok(self + .push_parts(&header, &payload, now_ms)? + .map(|(metadata, bytes)| ReassembledPreviewBuffer { + metadata, + encoded: bytes.into_array_buffer(), + })) + } +} + #[cfg(feature = "ws-client-wasm")] #[derive(Debug, Clone)] pub struct PreviewFrameView { diff --git a/crates/hypercolor-leptos-ext/tests/browser_preview_reassembly_tests.rs b/crates/hypercolor-leptos-ext/tests/browser_preview_reassembly_tests.rs new file mode 100644 index 000000000..e897f8f71 --- /dev/null +++ b/crates/hypercolor-leptos-ext/tests/browser_preview_reassembly_tests.rs @@ -0,0 +1,279 @@ +#![cfg(all(target_arch = "wasm32", feature = "ws-client-wasm"))] + +//! The JS-backed reassembler shares the native state machine, so every +//! step here runs through both and they must agree on the outcome and on +//! the bytes a completed publication carries. + +use bytes::Bytes; +use hypercolor_leptos_ext::ws::{ + DEFAULT_PREVIEW_MAX_MESSAGE_BYTES, JsPreviewPublicationBuffer, PreviewCancelFrame, + PreviewChunkError, PreviewChunkReassembler, PreviewFrame, PreviewFrameChannel, + PreviewFrameView, PreviewPixelFormat, PreviewPublicationMetadata, PreviewReassemblyLimits, + PreviewStreamId, ReassembledPreviewBuffer, ReassembledPreviewPublication, ScreenZonesFrame, + ZonePreviewFrame, split_preview_publication, +}; +use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + +wasm_bindgen_test_configure!(run_in_browser); + +type NativeOutcome = Result, PreviewChunkError>; +type JsOutcome = Result, PreviewChunkError>; + +struct Pair { + native: PreviewChunkReassembler, + js: PreviewChunkReassembler, +} + +impl Pair { + fn new(limits: PreviewReassemblyLimits) -> Self { + Self { + native: PreviewChunkReassembler::new(limits), + js: PreviewChunkReassembler::with_limits(limits), + } + } + + /// Push one chunk through both and return the completed bytes, after + /// checking both sides agree. + fn push(&mut self, chunk: &Bytes, now_ms: u64) -> Result, PreviewChunkError> { + let native = self.native.push_at(chunk, now_ms); + let js = self.js.push_array_buffer_at(&array_buffer(chunk), now_ms); + assert_outcomes_agree(&native, &js); + assert_eq!(self.native.partial_count(), self.js.partial_count()); + assert_eq!(self.native.reserved_bytes(), self.js.reserved_bytes()); + assert_eq!(self.native.tombstone_count(), self.js.tombstone_count()); + assert_eq!(self.native.state_bytes(), self.js.state_bytes()); + assert_eq!(self.native.tombstone_bytes(), self.js.tombstone_bytes()); + assert_eq!( + self.native.superseded_publications(), + self.js.superseded_publications() + ); + assert_eq!( + self.native.expired_publications(), + self.js.expired_publications() + ); + native.map(|completed| completed.map(|publication| publication.encoded)) + } + + fn cancel(&mut self, cancellation: &PreviewCancelFrame) { + assert_eq!( + self.native.cancel_publication(cancellation), + self.js.cancel_publication(cancellation) + ); + assert_eq!(self.native.partial_count(), self.js.partial_count()); + assert_eq!(self.native.reserved_bytes(), self.js.reserved_bytes()); + } +} + +fn array_buffer(bytes: &[u8]) -> js_sys::ArrayBuffer { + js_sys::Uint8Array::from(bytes).buffer() +} + +fn js_bytes(buffer: &js_sys::ArrayBuffer) -> Vec { + js_sys::Uint8Array::new(buffer).to_vec() +} + +fn assert_outcomes_agree(native: &NativeOutcome, js: &JsOutcome) { + match (native, js) { + (Ok(None), Ok(None)) => {} + (Ok(Some(native)), Ok(Some(js))) => { + assert_eq!(native.metadata, js.metadata); + assert_eq!(native.encoded.as_ref(), js_bytes(&js.encoded).as_slice()); + } + (Err(native), Err(js)) => assert_eq!(native, js), + (native, js) => panic!("reassemblers disagree: native {native:?}, js {js:?}"), + } +} + +fn canvas_frame(frame_number: u32, width: u32, height: u32) -> (Bytes, PreviewPublicationMetadata) { + let pixel_bytes = usize::try_from(width * height * 4).expect("fixture fits"); + let payload: Vec = (0..pixel_bytes) + .map(|index| u8::try_from(index % 251).expect("below 251")) + .collect(); + let frame = PreviewFrame { + channel: PreviewFrameChannel::Canvas, + frame_number, + timestamp_ms: frame_number * 16, + width, + height, + format: PreviewPixelFormat::Rgba, + payload: Bytes::from(payload), + }; + let metadata = PreviewPublicationMetadata { + stream: PreviewStreamId::Passive(frame.channel), + publication_id: u64::from(frame_number), + frame_number, + timestamp_ms: frame.timestamp_ms, + width, + height, + format: frame.format, + }; + (frame.encode(), metadata) +} + +#[wasm_bindgen_test] +fn a_chunked_canvas_frame_reassembles_in_js_byte_for_byte() { + let (encoded, metadata) = canvas_frame(7, 640, 480); + let chunks = split_preview_publication(&encoded, &metadata, DEFAULT_PREVIEW_MAX_MESSAGE_BYTES) + .expect("chunks split"); + assert!( + chunks.len() > 1, + "a 640x480 RGBA frame needs several messages" + ); + + let mut pair = Pair::new(PreviewReassemblyLimits::default()); + let mut completed = None; + for (index, chunk) in chunks.iter().enumerate() { + let outcome = pair.push(chunk, 1).expect("valid chunk"); + assert_eq!(outcome.is_some(), index + 1 == chunks.len()); + completed = outcome.or(completed); + } + assert_eq!(completed.as_ref(), Some(&encoded)); + + let mut js = PreviewChunkReassembler::::with_limits( + PreviewReassemblyLimits::default(), + ); + let mut assembled = None; + for chunk in &chunks { + assembled = js + .push_array_buffer_at(&array_buffer(chunk), 1) + .expect("valid chunk") + .or(assembled); + } + let assembled = assembled.expect("publication completes"); + let view = PreviewFrameView::decode_array_buffer(&assembled.encoded).expect("view decodes"); + assert_eq!((view.width, view.height), (640, 480)); + assert_eq!(view.format, PreviewPixelFormat::Rgba); + assert_eq!(view.payload.length(), 640 * 480 * 4); + assert_eq!(view.rgba_at(1), Some([4, 5, 6, 7])); +} + +#[wasm_bindgen_test] +fn one_byte_chunks_reassemble_in_js_for_screen_zones_and_zone_streams() { + let screen_zones = ScreenZonesFrame { + frame_number: 2, + timestamp_ms: 22, + source_width: 3840, + source_height: 2160, + grid_cols: 1, + grid_rows: 1, + letterbox: [0; 4], + payload: Bytes::from_static(&[4, 5, 6]), + }; + let screen_metadata = PreviewPublicationMetadata { + stream: PreviewStreamId::ScreenZones, + publication_id: 2, + frame_number: 2, + timestamp_ms: 22, + width: 3840, + height: 2160, + format: PreviewPixelFormat::Rgb, + }; + let zone = ZonePreviewFrame { + scene_id: [0x11; 16], + zone_id: [0x22; 16], + frame_number: 3, + timestamp_ms: 33, + width: 1, + height: 1, + format: PreviewPixelFormat::Rgb, + payload: Bytes::from_static(&[7, 8, 9]), + }; + let zone_metadata = PreviewPublicationMetadata { + stream: PreviewStreamId::Zone { + scene_id: zone.scene_id, + zone_id: zone.zone_id, + }, + publication_id: 3, + frame_number: 3, + timestamp_ms: 33, + width: 1, + height: 1, + format: PreviewPixelFormat::Rgb, + }; + for (encoded, metadata, identity_len) in [ + (screen_zones.encode(), screen_metadata, 0), + (zone.encode(), zone_metadata, 32), + ] { + let max_message_bytes = + hypercolor_leptos_ext::ws::PREVIEW_CHUNK_FIXED_HEADER_LEN + identity_len + 1; + let chunks = split_preview_publication(&encoded, &metadata, max_message_bytes) + .expect("one-byte chunks"); + assert_eq!(chunks.len(), encoded.len()); + let mut pair = Pair::new(PreviewReassemblyLimits::default()); + let mut completed = None; + for chunk in &chunks { + completed = pair.push(chunk, 1).expect("valid chunk").or(completed); + } + assert_eq!(completed, Some(encoded)); + } +} + +#[wasm_bindgen_test] +fn js_and_native_reassemblers_agree_on_misordered_superseded_and_cancelled_chunks() { + let (first, first_metadata) = canvas_frame(1, 64, 48); + let (second, second_metadata) = canvas_frame(2, 64, 48); + let (third, third_metadata) = canvas_frame(3, 64, 48); + let first_chunks = split_preview_publication(&first, &first_metadata, 4096).expect("split"); + let second_chunks = split_preview_publication(&second, &second_metadata, 4096).expect("split"); + let third_chunks = split_preview_publication(&third, &third_metadata, 4096).expect("split"); + assert!(first_chunks.len() >= 3); + + let mut pair = Pair::new(PreviewReassemblyLimits::default()); + // A publication cannot start mid-way. + assert!(pair.push(&first_chunks[1], 10).is_err()); + assert_eq!(pair.push(&first_chunks[0], 11), Ok(None)); + // A repeated chunk and a skipped one are both rejected. + assert!(pair.push(&first_chunks[0], 12).is_err()); + assert!(pair.push(&first_chunks[2], 13).is_err()); + // A newer publication supersedes the partial one, and the older is stale. + assert_eq!(pair.push(&second_chunks[0], 14), Ok(None)); + assert!(pair.push(&first_chunks[0], 15).is_err()); + let mut completed = None; + for chunk in &second_chunks[1..] { + completed = pair + .push(chunk, 16) + .expect("second continues") + .or(completed); + } + assert_eq!(completed, Some(second)); + // A cancelled publication drops its partial state on both sides. + assert_eq!(pair.push(&third_chunks[0], 17), Ok(None)); + pair.cancel(&PreviewCancelFrame { + stream: third_metadata.stream.clone(), + publication_id: third_metadata.publication_id, + }); + assert!(pair.push(&third_chunks[1], 18).is_err()); +} + +#[wasm_bindgen_test] +fn js_and_native_reassemblers_expire_an_idle_partial_together() { + let (encoded, metadata) = canvas_frame(4, 64, 48); + let chunks = split_preview_publication(&encoded, &metadata, 4096).expect("split"); + let limits = PreviewReassemblyLimits::default(); + let mut pair = Pair::new(limits); + assert_eq!(pair.push(&chunks[0], 100), Ok(None)); + let later = 100 + limits.max_idle_ms; + assert_eq!(pair.native.expire_at(later), pair.js.expire_at(later)); + assert_eq!(pair.native.partial_count(), 0); + assert_eq!(pair.js.partial_count(), 0); + assert!(pair.push(&chunks[1], later + 1).is_err()); +} + +#[wasm_bindgen_test] +fn an_oversized_message_is_rejected_before_it_is_decoded() { + let limits = PreviewReassemblyLimits { + max_message_bytes: 64, + ..PreviewReassemblyLimits::default() + }; + let mut pair = Pair::new(limits); + let garbage = Bytes::from(vec![0x0F; 65]); + assert!(matches!( + pair.push(&garbage, 1), + Err(PreviewChunkError::MessageBudgetExceeded { .. }) + )); + let short = Bytes::from_static(&[0x0F, 1, 0]); + assert!(matches!( + pair.push(&short, 2), + Err(PreviewChunkError::TooShort { .. }) + )); +} diff --git a/crates/hypercolor-leptos-ext/tests/browser_raf_tests.rs b/crates/hypercolor-leptos-ext/tests/browser_raf_tests.rs index e02da1a66..2b6ea52fc 100644 --- a/crates/hypercolor-leptos-ext/tests/browser_raf_tests.rs +++ b/crates/hypercolor-leptos-ext/tests/browser_raf_tests.rs @@ -25,6 +25,33 @@ async fn scheduler_runs_once_for_single_schedule() { assert!(!scheduler.is_pending()); } +#[wasm_bindgen_test(async)] +async fn continuous_schedule_fires_every_frame_until_paused() { + let calls = Rc::new(Cell::new(0)); + let calls_handle = Rc::clone(&calls); + let scheduler = Scheduler::new(move |_| { + calls_handle.set(calls_handle.get() + 1); + }); + + scheduler.schedule_continuous(); + for _ in 0..4 { + wait_for_animation_frame().await; + } + let while_running = calls.get(); + assert!(while_running >= 3, "only {while_running} frames ran"); + + scheduler.pause(); + assert!(!scheduler.is_pending()); + wait_for_animation_frame().await; + wait_for_animation_frame().await; + assert_eq!(calls.get(), while_running); + + scheduler.schedule(); + wait_for_animation_frame().await; + wait_for_animation_frame().await; + assert_eq!(calls.get(), while_running + 1); +} + async fn wait_for_animation_frame() { let promise = js_sys::Promise::new(&mut |resolve, _reject| { let callback = wasm_bindgen::closure::Closure::once(move || { diff --git a/crates/hypercolor-ui/input.css b/crates/hypercolor-ui/input.css index 82564b483..bb362c62c 100644 --- a/crates/hypercolor-ui/input.css +++ b/crates/hypercolor-ui/input.css @@ -90,6 +90,14 @@ button:not(:disabled), scrollbar-color: var(--scrollbar-thumb) transparent; } +/* Buttons, links, inline text and icons never scroll, yet they still carry + `scrollbar-color`, which tracks the ambient hue. Every hue publish would + then start a scrollbar-color transition on each one wearing + `transition-all`, animating chrome that has no scrollbar to tint. */ +button, a, span, svg { + scrollbar-color: auto; +} + *::-webkit-scrollbar { width: 5px; height: 5px; @@ -1058,21 +1066,36 @@ input[type="range"].slider-silk::-moz-range-track { /* Preview panel glow — ambient-reactive edge glow around the live canvas. Includes the `.edge-glow` hairline so stacking both classes adds the - ambient halo without dropping the neutral edge ring. */ + ambient halo without dropping the neutral edge ring. + + The halo drifts toward each published hue through `--ambient-hue-drift`, + a registered number that does not inherit. Interpolating it on this one + element keeps every frame of the drift here: nothing below inherits it, + so the cabinet's subtree never restyles while the hue settles, and + `--ambient-hue` itself only changes once per publish. The halo colors are + rebuilt from the ambient channel tokens because `--ambient-glow` and + `--ambient-border` already substituted the undrifted root hue. */ +@property --ambient-hue-drift { + syntax: ""; + inherits: false; + initial-value: 320; +} + .preview-glow { - box-shadow: 0 0 40px var(--ambient-glow), - 0 0 2px var(--ambient-border), + --ambient-hue-drift: var(--ambient-hue); + box-shadow: 0 0 40px oklch(var(--ambient-l) var(--ambient-c) var(--ambient-hue-drift) / var(--ambient-glow-alpha)), + 0 0 2px oklch(var(--ambient-l) var(--ambient-c) var(--ambient-hue-drift) / var(--ambient-border-alpha)), 0 0 1px var(--border-subtle), inset 0 1px 0 rgba(255, 255, 255, 0.03); - transition: box-shadow 2s var(--ease-silk); + transition: --ambient-hue-drift 2s var(--ease-silk); } /* The preview cabinet runs both its accent-border crossfade and the ambient - glow; the transitions must ride one declaration or the later rule's + drift; the transitions must ride one declaration or the later rule's `transition` would silently drop the border crossfade. */ .cabinet-accent-transition.preview-glow { transition: border-top-color 0.45s var(--ease-silk), - box-shadow 2s var(--ease-silk); + --ambient-hue-drift 2s var(--ease-silk); } /* Command palette / modal glow — cinematic ambient */ diff --git a/crates/hypercolor-ui/src/ambient.rs b/crates/hypercolor-ui/src/ambient.rs new file mode 100644 index 000000000..7681ced38 --- /dev/null +++ b/crates/hypercolor-ui/src/ambient.rs @@ -0,0 +1,91 @@ +//! When the chrome's ambient hue moves. +//! +//! The shell publishes the live canvas's dominant hue as `--ambient-hue` +//! on the document element, where every `--ambient-*` token resolves it. +//! A write there restyles the whole document, so it happens on a slow, +//! fixed cadence and only for a change the eye could see. CSS interpolates +//! between published values (`--ambient-hue-drift` in `input.css`), so the +//! tint still drifts instead of stepping. + +/// Least time between two published hues. +/// +/// The tint is decorative chrome at single-digit alpha with no cadence of +/// its own to honor, unlike the preview, and the halo spends two seconds +/// drifting to each new value. One publish a second keeps that drift +/// continuous while capping document-wide restyles at one a second. +pub const AMBIENT_PUBLISH_INTERVAL_MS: f64 = 1_000.0; + +/// Smallest hue change, in degrees, worth a publish. +/// +/// The dominant hue of a steady effect wanders by a few degrees from one +/// sample to the next. Below this the tint cannot visibly change, and +/// publishing would restart the drift for nothing. +pub const AMBIENT_HUE_DEADBAND_DEG: f64 = 4.0; + +/// The hue `tokens/semantic.css` declares before anything is published. +pub const AMBIENT_HUE_FALLBACK_DEG: f64 = 320.0; + +/// Paces `--ambient-hue` writes and keeps the published value continuous. +/// +/// Published values are unwrapped: a move from 350° to 10° publishes 370, +/// not 10, so CSS interpolating the number turns 20° the short way rather +/// than 340° the long way. OKLCH reads any angle modulo 360. The first +/// publish unwraps against the value CSS already holds for the same reason. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AmbientHuePublisher { + published: f64, + published_at_ms: f64, +} + +impl Default for AmbientHuePublisher { + fn default() -> Self { + Self::starting_at(AMBIENT_HUE_FALLBACK_DEG) + } +} + +impl AmbientHuePublisher { + /// A publisher continuing from `hue_deg`, the value `--ambient-hue` + /// holds now. Its first move is not held back by the interval. + #[must_use] + pub fn starting_at(hue_deg: f64) -> Self { + Self { + published: if hue_deg.is_finite() { + hue_deg + } else { + AMBIENT_HUE_FALLBACK_DEG + }, + published_at_ms: f64::NEG_INFINITY, + } + } + + /// Offer a freshly measured hue in degrees. Returns the value to write + /// as `--ambient-hue`, or `None` when the tint should stay put. + pub fn offer(&mut self, hue_deg: f64, now_ms: f64) -> Option { + if !hue_deg.is_finite() || !now_ms.is_finite() { + return None; + } + if now_ms - self.published_at_ms < AMBIENT_PUBLISH_INTERVAL_MS { + return None; + } + let delta = shortest_hue_delta(self.published, hue_deg); + if delta.abs() < AMBIENT_HUE_DEADBAND_DEG { + return None; + } + self.published += delta; + self.published_at_ms = now_ms; + Some(self.published) + } + + /// The value `--ambient-hue` holds, unwrapped. + #[must_use] + pub const fn published(&self) -> f64 { + self.published + } +} + +/// Signed turn from `from` to `to` along the shorter arc, in `(-180, 180]`. +#[must_use] +pub fn shortest_hue_delta(from: f64, to: f64) -> f64 { + let delta = (to - from).rem_euclid(360.0); + if delta > 180.0 { delta - 360.0 } else { delta } +} diff --git a/crates/hypercolor-ui/src/components/shell.rs b/crates/hypercolor-ui/src/components/shell.rs index daca84e6c..8782a6aa0 100644 --- a/crates/hypercolor-ui/src/components/shell.rs +++ b/crates/hypercolor-ui/src/components/shell.rs @@ -2,6 +2,7 @@ use hypercolor_leptos_ext::events::Input; use hypercolor_leptos_ext::events::document as browser_document; +use hypercolor_leptos_ext::prelude::now_ms; use leptos::prelude::*; use leptos_icons::Icon; use leptos_router::hooks::use_navigate; @@ -9,6 +10,7 @@ use wasm_bindgen::JsCast; use hypercolor_types::scene::SceneMutationMode; +use crate::ambient::AmbientHuePublisher; use crate::app::{EffectsContext, FrameAnalysisContext}; use crate::components::mobile_nav::MobileNav; use crate::components::page_search_bar::PAGE_SEARCH_INPUT_ID; @@ -31,6 +33,18 @@ fn targets_editable(ev: &leptos::ev::KeyboardEvent) -> bool { tag == "input" || tag == "textarea" || tag == "select" || el.is_content_editable() } +/// The hue a previous shell left on the document element, so a remounted +/// shell keeps unwrapping from it instead of from the CSS fallback. +fn published_ambient_hue() -> Option { + let root = browser_document()?.document_element()?; + let value = root + .dyn_ref::()? + .style() + .get_property_value("--ambient-hue") + .ok()?; + value.trim().parse().ok() +} + /// Focus the current page's search bar (the `/` kbd hint in `PageSearchBar`). fn focus_page_search() { if let Some(doc) = browser_document() @@ -51,19 +65,26 @@ pub fn Shell(children: Children) -> impl IntoView { // custom-property `var()` substitution happens where a property is // *declared*, and every `--ambient-*` token plus the scrollbar tints are // declared in `:root` blocks of tokens/semantic.css, so a hue set any - // lower in the tree would never reach them. + // lower in the tree would never reach them. A `:root` write restyles the + // whole document, so `AmbientHuePublisher` paces it; CSS drifts the + // halo between published values. let frame_analysis = use_context::(); - let last_ambient_hue = StoredValue::new(None::); + let ambient_publisher = StoredValue::new(published_ambient_hue().map_or_else( + AmbientHuePublisher::default, + AmbientHuePublisher::starting_at, + )); if let Some(frame_analysis) = frame_analysis { Effect::new(move |_| { let Some(analysis) = frame_analysis.live_canvas.get() else { return; }; - let hue = analysis.dominant_hue.round() as i16; - if last_ambient_hue.get_value() == Some(hue) { + let Some(hue) = ambient_publisher + .try_update_value(|publisher| publisher.offer(analysis.dominant_hue, now_ms())) + .flatten() + else { return; - } + }; if let Some(doc) = browser_document() && let Some(root) = doc.document_element() @@ -71,8 +92,7 @@ pub fn Shell(children: Children) -> impl IntoView { { let _ = html_el .style() - .set_property("--ambient-hue", &hue.to_string()); - last_ambient_hue.set_value(Some(hue)); + .set_property("--ambient-hue", &format!("{hue:.1}")); } }); } diff --git a/crates/hypercolor-ui/src/lib.rs b/crates/hypercolor-ui/src/lib.rs index 3decf1020..ab9d5c96a 100644 --- a/crates/hypercolor-ui/src/lib.rs +++ b/crates/hypercolor-ui/src/lib.rs @@ -8,6 +8,7 @@ //! shim over [`run`]. That keeps every module reachable from integration //! tests as `hypercolor_ui::...` without `#[path]` source includes. +pub mod ambient; pub mod api; pub mod app; pub mod apply_target; diff --git a/crates/hypercolor-ui/src/ws/connection.rs b/crates/hypercolor-ui/src/ws/connection.rs index a28161057..2b069208c 100644 --- a/crates/hypercolor-ui/src/ws/connection.rs +++ b/crates/hypercolor-ui/src/ws/connection.rs @@ -64,32 +64,50 @@ fn preview_now_ms() -> u64 { .unwrap_or(u64::MAX) } -fn clear_preview_decoder( - decoder: &Rc>, - timeout: &Rc>>, -) { - timeout.borrow_mut().take(); +/// The timer that sweeps idle partial preview publications, and when it fires. +struct PreviewExpiryTimer { + _handle: BrowserTimeoutHandle, + deadline_ms: u64, +} + +type PreviewExpirySlot = Rc>>; + +fn clear_preview_decoder(decoder: &Rc>, timer: &PreviewExpirySlot) { + timer.borrow_mut().take(); decoder.borrow_mut().clear(); } -fn schedule_preview_expiry( - decoder: &Rc>, - timeout: &Rc>>, -) { - timeout.borrow_mut().take(); +/// Keep a sweep armed no later than the earliest idle deadline. +/// +/// Every chunked frame leaves a partial publication between its chunks, so +/// re-arming per message would mint a timer, and the JS closure behind it, +/// for every frame. A sweep that fires before anything is idle expires +/// nothing and re-arms for the deadline then pending, so an armed timer +/// only has to be replaced when a deadline lands before it, and an idle +/// partial is still swept on time. +fn schedule_preview_expiry(decoder: &Rc>, timer: &PreviewExpirySlot) { let Some(deadline_ms) = decoder.borrow().next_expiry_ms() else { return; }; + if timer + .borrow() + .as_ref() + .is_some_and(|armed| armed.deadline_ms <= deadline_ms) + { + return; + } let delay = Duration::from_millis(deadline_ms.saturating_sub(preview_now_ms())); let decoder_for_timeout = Rc::clone(decoder); - let timeout_for_callback = Rc::clone(timeout); - let timeout_for_schedule = Rc::clone(timeout); + let timer_for_callback = Rc::clone(timer); let handle = browser_set_timeout(delay, move || { - timeout_for_callback.borrow_mut().take(); + timer_for_callback.borrow_mut().take(); decoder_for_timeout.borrow_mut().expire_at(preview_now_ms()); - schedule_preview_expiry(&decoder_for_timeout, &timeout_for_schedule); + schedule_preview_expiry(&decoder_for_timeout, &timer_for_callback); + }); + *timer.borrow_mut() = Some(PreviewExpiryTimer { + _handle: handle, + deadline_ms, }); - *timeout.borrow_mut() = Some(handle); } fn quantize_preview_fps(value: f64) -> f32 { @@ -403,7 +421,7 @@ impl WsManager { let generation = socket_generation.get_value().wrapping_add(1); socket_generation.set_value(generation); let preview_decoder = Rc::new(RefCell::new(PreviewBinaryDecoder::default())); - let preview_expiry_timeout = Rc::new(RefCell::new(None::)); + let preview_expiry_timeout: PreviewExpirySlot = Rc::new(RefCell::new(None)); let event_preview_decoder = Rc::clone(&preview_decoder); let event_preview_expiry_timeout = Rc::clone(&preview_expiry_timeout); let process_event: Rc = Rc::new(move |event| { @@ -695,9 +713,16 @@ impl WsManager { connect.set_value(Some(connect_fn)); - // Preview subscription effect — reacts to FPS cap / visibility changes + // Preview subscription effect: reacts to FPS cap and visibility changes. + // + // Every preview effect waits for `Connected`. A socket that is still + // opening rejects sends, and a request recorded as sent but never + // delivered would match every later rerun and never be retried, + // leaving the stream dark after a reconnect. The connect and close + // paths clear the recorded requests, and the move to `Connected` + // reruns these effects against the open socket. Effect::new(move |_| { - if !frame_topics { + if !frame_topics || connection_state.get() != ConnectionState::Connected { return; } let engine_target = engine_preview_target.get(); @@ -706,8 +731,13 @@ impl WsManager { let width_cap = preview_width_cap.get(); let is_visible = page_visible.get(); let window_visible = app_window_visible.get(); - if !should_stream_preview(window_visible, engine_target, consumer_count) { - if let Some(ws) = ws_handle.get_value() { + if !should_stream_preview(is_visible, window_visible, engine_target, consumer_count) { + // Only a requested stream has anything to unsubscribe. The + // effect reruns on every engine target report, about twice + // a second, and each rerun would otherwise send another. + if requested_preview.get_value().is_some() + && let Some(ws) = ws_handle.get_value() + { clear_preview_subscription( requested_preview, &set_preview_target_fps, @@ -728,21 +758,22 @@ impl WsManager { engine_target, client_cap, width_cap, - is_visible, ); } }); Effect::new(move |_| { - if !frame_topics { + if !frame_topics || connection_state.get() != ConnectionState::Connected { return; } let engine_target = engine_preview_target.get(); let consumer_count = screen_preview_consumers.get(); let is_visible = page_visible.get(); let window_visible = app_window_visible.get(); - if !should_stream_preview(window_visible, engine_target, consumer_count) { - if let Some(ws) = ws_handle.get_value() { + if !should_stream_preview(is_visible, window_visible, engine_target, consumer_count) { + if requested_screen_preview.get_value().is_some() + && let Some(ws) = ws_handle.get_value() + { clear_screen_preview_subscription( requested_screen_preview, &set_screen_canvas_frame, @@ -757,19 +788,18 @@ impl WsManager { ws.as_ref(), requested_screen_preview, engine_target, - is_visible, ); } }); // Zone frames are tiny and carry no per-client config, so the // subscription is a simple on/off keyed to consumers, connection - // state, and app window visibility. + // state, and page and app window visibility. Effect::new(move |_| { let connected = connection_state.get() == ConnectionState::Connected; let consumer_count = screen_zones_consumers.get(); - let window_visible = app_window_visible.get(); - let wants_stream = connected && window_visible && consumer_count > 0; + let visible = page_visible.get() && app_window_visible.get(); + let wants_stream = connected && visible && consumer_count > 0; if wants_stream { if !screen_zones_requested.get_value() @@ -788,15 +818,17 @@ impl WsManager { }); Effect::new(move |_| { - if !frame_topics { + if !frame_topics || connection_state.get() != ConnectionState::Connected { return; } let engine_target = engine_preview_target.get(); let consumer_count = web_viewport_preview_consumers.get(); let is_visible = page_visible.get(); let window_visible = app_window_visible.get(); - if !should_stream_preview(window_visible, engine_target, consumer_count) { - if let Some(ws) = ws_handle.get_value() { + if !should_stream_preview(is_visible, window_visible, engine_target, consumer_count) { + if requested_web_viewport_preview.get_value().is_some() + && let Some(ws) = ws_handle.get_value() + { clear_web_viewport_preview_subscription( requested_web_viewport_preview, &set_web_viewport_canvas_frame, @@ -811,7 +843,6 @@ impl WsManager { ws.as_ref(), requested_web_viewport_preview, engine_target, - is_visible, ); } }); @@ -950,8 +981,9 @@ impl WsManager { visibility_change_callback.set_value(Some(on_visibility_change)); } - // Tauri native-window visibility listener. Browser tab visibility only - // caps preview FPS; a hidden app window should unsubscribe entirely. + // Tauri native-window visibility listener. Page visibility misses some + // native minimize and hide states, so the window's own signal gates + // previews alongside it. if let Some(window) = browser_window() { tauri_visibility_change_callback.update_value(|handle| { if let Some(mut handle) = handle.take() { diff --git a/crates/hypercolor-ui/src/ws/messages.rs b/crates/hypercolor-ui/src/ws/messages.rs index 3eb353149..20b3d5c34 100644 --- a/crates/hypercolor-ui/src/ws/messages.rs +++ b/crates/hypercolor-ui/src/ws/messages.rs @@ -7,14 +7,13 @@ use hypercolor_leptos_ext::prelude::now_ms; pub(super) use hypercolor_leptos_ext::ws::PreviewFrameChannel; pub use hypercolor_leptos_ext::ws::ScreenZonesFrame; use hypercolor_leptos_ext::ws::{ - DISPLAY_PREVIEW_FRAME_TAG, DisplayPreviewFrame, DisplayPreviewFrameView, - EXTENDED_SCREEN_ZONES_FRAME_TAG, INTERACTIVE_PREVIEW_FRAME_TAG, InteractivePreviewFrame, - InteractivePreviewFrameView, PREVIEW_CANCEL_FRAME_TAG, PREVIEW_CHUNK_FRAME_TAG, - PreviewCancelFrame, PreviewChunkReassembler, PreviewFrame, PreviewPublicationMetadata, - PreviewReassemblyLimits, PreviewStreamId, ReassembledPreviewPublication, + DISPLAY_PREVIEW_FRAME_TAG, DisplayPreviewFrameView, EXTENDED_SCREEN_ZONES_FRAME_TAG, + INTERACTIVE_PREVIEW_FRAME_TAG, InteractivePreviewFrameView, JsPreviewPublicationBuffer, + PREVIEW_CANCEL_FRAME_TAG, PREVIEW_CHUNK_FRAME_TAG, PreviewCancelFrame, PreviewChunkReassembler, + PreviewPublicationMetadata, PreviewReassemblyLimits, PreviewStreamId, ReassembledPreviewBuffer, SCREEN_ZONES_FRAME_TAG, WIDE_DISPLAY_PREVIEW_FRAME_TAG, WIDE_INTERACTIVE_PREVIEW_FRAME_TAG, WIDE_SCREEN_ZONES_FRAME_TAG, WIDE_ZONE_PREVIEW_FRAME_TAG, ZONE_PREVIEW_FRAME_TAG, - ZonePreviewFrame, ZonePreviewFrameView, + ZonePreviewFrameView, }; pub use hypercolor_leptos_ext::ws::{ PreviewFrameView as CanvasFrame, PreviewPixelFormat as CanvasPixelFormat, @@ -601,14 +600,17 @@ pub(super) enum PreviewBinaryMessage { ScreenZones(ScreenZonesFrame), } +/// Decodes binary preview messages. Chunked publications reassemble in JS +/// (`JsPreviewPublicationBuffer`), so a frame too large for one message +/// still reaches the presenter without passing through WASM memory. pub(super) struct PreviewBinaryDecoder { - chunks: PreviewChunkReassembler, + chunks: PreviewChunkReassembler, } impl Default for PreviewBinaryDecoder { fn default() -> Self { Self { - chunks: PreviewChunkReassembler::new(PreviewReassemblyLimits::default()), + chunks: PreviewChunkReassembler::with_limits(PreviewReassemblyLimits::default()), } } } @@ -622,9 +624,8 @@ impl PreviewBinaryDecoder { let buffer = frame.into_array_buffer(); let bytes = js_sys::Uint8Array::new(&buffer); if bytes.length() > 0 && bytes.get_index(0) == PREVIEW_CHUNK_FRAME_TAG { - let encoded = Bytes::from(bytes.to_vec()); - return match self.chunks.push_at(&encoded, now_ms) { - Ok(Some(publication)) => decode_reassembled_preview(&publication), + return match self.chunks.push_array_buffer_at(&buffer, now_ms) { + Ok(Some(publication)) => decode_reassembled_preview(publication), Ok(None) => None, Err(error) => { log::warn!("rejected preview chunk publication: {error}"); @@ -679,86 +680,21 @@ fn decode_direct_preview(buffer: js_sys::ArrayBuffer) -> Option Option { - let metadata = &publication.metadata; - let decoded = match &metadata.stream { - PreviewStreamId::Passive(expected_channel) => { - let frame = PreviewFrame::decode_bytes(&publication.encoded).ok()?; - if frame.channel != *expected_channel { - return None; - } - PreviewBinaryMessage::Frame( - frame.channel, - CanvasFrame { - channel: frame.channel, - frame_number: frame.frame_number, - timestamp_ms: frame.timestamp_ms, - width: frame.width, - height: frame.height, - format: frame.format, - payload: js_sys::Uint8Array::from(frame.payload.as_ref()), - }, - ) - } - PreviewStreamId::Zone { scene_id, zone_id } => { - let frame = ZonePreviewFrame::decode_bytes(&publication.encoded).ok()?; - if frame.scene_id != *scene_id || frame.zone_id != *zone_id { - return None; - } - PreviewBinaryMessage::Zone(ZonePreviewFrameView { - scene_id: frame.scene_id, - zone_id: frame.zone_id, - frame_number: frame.frame_number, - timestamp_ms: frame.timestamp_ms, - width: frame.width, - height: frame.height, - format: frame.format, - payload: js_sys::Uint8Array::from(frame.payload.as_ref()), - }) - } - PreviewStreamId::Interactive(expected_preview_id) => { - let frame = InteractivePreviewFrame::decode_bytes(&publication.encoded).ok()?; - if frame.preview_id != *expected_preview_id { - return None; - } - PreviewBinaryMessage::Interactive( - frame.preview_id, - CanvasFrame { - channel: PreviewFrameChannel::Canvas, - frame_number: frame.frame_number, - timestamp_ms: frame.timestamp_ms, - width: frame.width, - height: frame.height, - format: frame.format, - payload: js_sys::Uint8Array::from(frame.payload.as_ref()), - }, - ) - } - PreviewStreamId::Display(expected_device_id) => { - let frame = DisplayPreviewFrame::decode_bytes(&publication.encoded).ok()?; - if frame.device_id != *expected_device_id { - return None; - } - PreviewBinaryMessage::Display( - frame.device_id, - CanvasFrame { - channel: PreviewFrameChannel::Canvas, - frame_number: frame.frame_number, - timestamp_ms: frame.timestamp_ms, - width: frame.width, - height: frame.height, - format: frame.format, - payload: js_sys::Uint8Array::from(frame.payload.as_ref()), - }, - ) - } - PreviewStreamId::ScreenZones => { - PreviewBinaryMessage::ScreenZones(ScreenZonesFrame::decode(&publication.encoded).ok()?) - } + let ReassembledPreviewBuffer { metadata, encoded } = publication; + let Some(decoded) = decode_direct_preview(encoded) else { + log::warn!("rejected reassembled preview publication: frame does not decode"); + return None; }; - preview_metadata_matches(&decoded, metadata).then_some(decoded) + if !preview_metadata_matches(&decoded, &metadata) { + log::warn!("reassembled preview does not match its chunk metadata"); + return None; + } + Some(decoded) } fn preview_metadata_matches( diff --git a/crates/hypercolor-ui/src/ws/preview.rs b/crates/hypercolor-ui/src/ws/preview.rs index ff387bb2d..616490f22 100644 --- a/crates/hypercolor-ui/src/ws/preview.rs +++ b/crates/hypercolor-ui/src/ws/preview.rs @@ -13,7 +13,6 @@ use hypercolor_types::spatial::SpatialLayout; use super::transport::{WebSocketConnection, send_json}; pub const DEFAULT_PREVIEW_FPS_CAP: u32 = 60; -pub(super) const HIDDEN_TAB_PREVIEW_FPS_CAP: u32 = 6; pub(super) const SCREEN_PREVIEW_FPS_CAP: u32 = 15; pub(super) const WEB_VIEWPORT_PREVIEW_FPS_CAP: u32 = 15; const REMOTE_PREVIEW_WIDTH_MEDIUM: u32 = 640; @@ -66,25 +65,22 @@ impl PreviewSubscriptionRequest { } } -pub(super) fn desired_preview_fps( - engine_target_fps: u32, - client_cap: u32, - page_visible: bool, -) -> u32 { - let capped_target = engine_target_fps.clamp(1, 60).min(client_cap.clamp(1, 60)); - if page_visible { - capped_target - } else { - capped_target.min(HIDDEN_TAB_PREVIEW_FPS_CAP) - } +pub(super) fn desired_preview_fps(engine_target_fps: u32, client_cap: u32) -> u32 { + engine_target_fps.clamp(1, 60).min(client_cap.clamp(1, 60)) } +/// Whether a preview stream should be subscribed at all. +/// +/// A hidden page shows no frame, so it subscribes to none: the daemon stops +/// encoding for this client and the page stops decoding. Returning to view +/// resubscribes from scratch, the same path a reconnect takes. pub(super) const fn should_stream_preview( + page_visible: bool, app_window_visible: bool, engine_target_fps: u32, consumer_count: u32, ) -> bool { - app_window_visible && engine_target_fps > 0 && consumer_count > 0 + page_visible && app_window_visible && engine_target_fps > 0 && consumer_count > 0 } fn preview_canvas_format_for_host(hostname: &str) -> &'static str { @@ -110,10 +106,9 @@ pub(super) fn request_preview_subscription( engine_target_fps: u32, client_cap: u32, width_cap: u32, - page_visible: bool, ) { let request = PreviewSubscriptionRequest::canvas( - desired_preview_fps(engine_target_fps, client_cap, page_visible), + desired_preview_fps(engine_target_fps, client_cap), width_cap, ); if requested_preview_request.get_value() == Some(request) { @@ -142,10 +137,9 @@ pub(super) fn request_screen_preview_subscription( ws: &dyn WebSocketConnection, requested_preview_request: StoredValue>, engine_target_fps: u32, - page_visible: bool, ) { let request = PreviewSubscriptionRequest::canvas( - desired_preview_fps(engine_target_fps, SCREEN_PREVIEW_FPS_CAP, page_visible), + desired_preview_fps(engine_target_fps, SCREEN_PREVIEW_FPS_CAP), 0, ); if requested_preview_request.get_value() == Some(request) { @@ -173,12 +167,10 @@ pub(super) fn request_web_viewport_preview_subscription( ws: &dyn WebSocketConnection, requested_preview_request: StoredValue>, engine_target_fps: u32, - page_visible: bool, ) { let request = PreviewSubscriptionRequest::web_viewport(desired_preview_fps( engine_target_fps, WEB_VIEWPORT_PREVIEW_FPS_CAP, - page_visible, )); if requested_preview_request.get_value() == Some(request) { return; @@ -626,18 +618,20 @@ mod tests { } #[test] - fn hidden_page_caps_preview_fps() { - assert_eq!(desired_preview_fps(60, 60, false), 6); - assert_eq!(desired_preview_fps(20, 15, false), 6); - assert_eq!(desired_preview_fps(5, 60, false), 5); + fn preview_fps_is_the_lower_of_engine_target_and_client_cap() { + assert_eq!(desired_preview_fps(60, 60), 60); + assert_eq!(desired_preview_fps(60, 15), 15); + assert_eq!(desired_preview_fps(20, 60), 20); + assert_eq!(desired_preview_fps(120, 0), 1); } #[test] - fn hidden_tauri_window_disables_preview_streaming() { - assert!(should_stream_preview(true, 60, 1)); - assert!(!should_stream_preview(false, 60, 1)); - assert!(!should_stream_preview(true, 0, 1)); - assert!(!should_stream_preview(true, 60, 0)); + fn a_hidden_page_or_window_disables_preview_streaming() { + assert!(should_stream_preview(true, true, 60, 1)); + assert!(!should_stream_preview(false, true, 60, 1)); + assert!(!should_stream_preview(true, false, 60, 1)); + assert!(!should_stream_preview(true, true, 0, 1)); + assert!(!should_stream_preview(true, true, 60, 0)); } #[test] diff --git a/crates/hypercolor-ui/tests/ambient_tests.rs b/crates/hypercolor-ui/tests/ambient_tests.rs new file mode 100644 index 000000000..6088e3e4f --- /dev/null +++ b/crates/hypercolor-ui/tests/ambient_tests.rs @@ -0,0 +1,130 @@ +use hypercolor_ui::ambient::{ + AMBIENT_HUE_DEADBAND_DEG, AMBIENT_HUE_FALLBACK_DEG, AMBIENT_PUBLISH_INTERVAL_MS, + AmbientHuePublisher, shortest_hue_delta, +}; + +const LATER: f64 = AMBIENT_PUBLISH_INTERVAL_MS; + +#[test] +fn the_first_hue_publishes_at_once_unwrapped_from_the_css_fallback() { + let mut publisher = AmbientHuePublisher::default(); + assert_eq!(publisher.published(), AMBIENT_HUE_FALLBACK_DEG); + // 320 to 10 is 50 degrees forward, not 310 back through green. + assert_eq!(publisher.offer(10.0, 0.0), Some(370.0)); +} + +#[test] +fn a_first_hue_beside_the_fallback_leaves_the_css_alone() { + let mut publisher = AmbientHuePublisher::default(); + let near = AMBIENT_HUE_FALLBACK_DEG + AMBIENT_HUE_DEADBAND_DEG * 0.5; + assert_eq!(publisher.offer(near, 0.0), None); + assert_eq!(publisher.published(), AMBIENT_HUE_FALLBACK_DEG); +} + +#[test] +fn a_publisher_resuming_from_an_unwrapped_value_keeps_turning_the_short_way() { + // A remounted shell picks up where `--ambient-hue` was left, laps and all. + let mut publisher = AmbientHuePublisher::starting_at(1_090.0); + assert_eq!(publisher.offer(20.0, 0.0), Some(1_100.0)); +} + +#[test] +fn a_non_finite_starting_value_falls_back() { + let publisher = AmbientHuePublisher::starting_at(f64::NAN); + assert_eq!(publisher.published(), AMBIENT_HUE_FALLBACK_DEG); +} + +#[test] +fn a_change_inside_the_interval_waits_for_the_next_slot() { + let mut publisher = AmbientHuePublisher::starting_at(100.0); + assert_eq!(publisher.offer(120.0, 0.0), Some(120.0)); + assert_eq!(publisher.offer(160.0, LATER - 1.0), None); + assert_eq!(publisher.offer(160.0, LATER), Some(160.0)); +} + +#[test] +fn a_change_below_the_deadband_never_publishes() { + let mut publisher = AmbientHuePublisher::starting_at(100.0); + let jitter = AMBIENT_HUE_DEADBAND_DEG * 0.9; + assert_eq!(publisher.offer(100.0 + jitter, 0.0), None); + assert_eq!(publisher.offer(100.0 - jitter, LATER), None); + assert_eq!(publisher.published(), 100.0); +} + +#[test] +fn a_rejected_offer_does_not_restart_the_interval() { + let mut publisher = AmbientHuePublisher::starting_at(100.0); + assert_eq!(publisher.offer(140.0, 0.0), Some(140.0)); + assert_eq!(publisher.offer(141.0, LATER), None); + assert_eq!(publisher.offer(190.0, LATER + 1.0), Some(190.0)); +} + +#[test] +fn crossing_zero_publishes_an_unwrapped_value_the_short_way() { + let mut publisher = AmbientHuePublisher::starting_at(350.0); + assert_eq!(publisher.offer(10.0, 0.0), Some(370.0)); + assert_eq!(publisher.offer(340.0, LATER), Some(340.0)); +} + +#[test] +fn a_full_lap_keeps_climbing_instead_of_snapping_back() { + let mut publisher = AmbientHuePublisher::starting_at(0.0); + let mut now = 0.0; + let mut last = publisher.published(); + for step in 1..=12 { + let hue = f64::from(step * 30 % 360); + let next = publisher + .offer(hue, now) + .expect("every 30 degree step publishes"); + assert!( + (next - last - 30.0).abs() < 1e-9, + "step {step}: {last} -> {next}" + ); + last = next; + now += LATER; + } + assert!((last - 360.0).abs() < 1e-9); +} + +#[test] +fn non_finite_input_is_ignored() { + let mut publisher = AmbientHuePublisher::default(); + assert_eq!(publisher.offer(f64::NAN, 0.0), None); + assert_eq!(publisher.offer(120.0, f64::INFINITY), None); + assert_eq!(publisher.published(), AMBIENT_HUE_FALLBACK_DEG); +} + +#[test] +fn shortest_delta_takes_the_shorter_arc() { + assert!((shortest_hue_delta(350.0, 10.0) - 20.0).abs() < 1e-9); + assert!((shortest_hue_delta(10.0, 350.0) + 20.0).abs() < 1e-9); + assert!((shortest_hue_delta(370.0, 20.0) - 10.0).abs() < 1e-9); + assert!((shortest_hue_delta(0.0, 180.0) - 180.0).abs() < 1e-9); +} + +/// The fallback is the hue CSS holds before the first publish. If the token +/// or the drift property's initial value moved without it, the first publish +/// would unwrap against the wrong angle and could sweep the long way. +#[test] +fn the_fallback_matches_the_css_it_unwraps_against() { + let read = |path: &str| { + std::fs::read_to_string(format!("{}/{path}", env!("CARGO_MANIFEST_DIR"))) + .unwrap_or_else(|error| panic!("read {path}: {error}")) + }; + let declared = |css: &str, marker: &str| -> f64 { + let start = css.find(marker).expect("marker present") + marker.len(); + let value: String = css[start..] + .trim_start() + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '.') + .collect(); + value.parse().expect("numeric value") + }; + let tokens = read("tokens/semantic.css"); + let input = read("input.css"); + let drift = &input[input + .find("@property --ambient-hue-drift") + .expect("drift property registered")..]; + assert!((declared(&tokens, "--ambient-hue:") - AMBIENT_HUE_FALLBACK_DEG).abs() < f64::EPSILON); + assert!((declared(drift, "initial-value:") - AMBIENT_HUE_FALLBACK_DEG).abs() < f64::EPSILON); +} diff --git a/crates/hypercolor-ui/tokens/semantic.css b/crates/hypercolor-ui/tokens/semantic.css index 9ec807a01..81184309f 100644 --- a/crates/hypercolor-ui/tokens/semantic.css +++ b/crates/hypercolor-ui/tokens/semantic.css @@ -42,11 +42,19 @@ --accent-muted: oklch(0.65 0.30 320 / 0.12); --accent-subtle: oklch(0.65 0.30 320 / 0.06); - /* Ambient (dynamic — --ambient-hue set from Leptos) */ + /* Ambient (dynamic: --ambient-hue is set from Leptos). The colors are + built from channel tokens so a consumer that drifts its own copy of + the hue (.preview-glow) can rebuild them; a token declared here has + already substituted the root hue. Light mode overrides the channels. */ --ambient-hue: 320; - --ambient-glow: oklch(0.65 0.20 var(--ambient-hue) / 0.08); - --ambient-border: oklch(0.65 0.20 var(--ambient-hue) / 0.15); - --ambient-tint: oklch(0.65 0.20 var(--ambient-hue) / 0.04); + --ambient-l: 0.65; + --ambient-c: 0.20; + --ambient-glow-alpha: 0.08; + --ambient-border-alpha: 0.15; + --ambient-tint-alpha: 0.04; + --ambient-glow: oklch(var(--ambient-l) var(--ambient-c) var(--ambient-hue) / var(--ambient-glow-alpha)); + --ambient-border: oklch(var(--ambient-l) var(--ambient-c) var(--ambient-hue) / var(--ambient-border-alpha)); + --ambient-tint: oklch(var(--ambient-l) var(--ambient-c) var(--ambient-hue) / var(--ambient-tint-alpha)); /* Status */ --status-success: oklch(0.85 0.22 155); @@ -119,10 +127,16 @@ --accent-muted: oklch(0.58 0.28 320 / 0.10); --accent-subtle: oklch(0.58 0.28 320 / 0.05); - /* Ambient (lower intensity on light) */ - --ambient-glow: oklch(0.58 0.15 var(--ambient-hue) / 0.06); - --ambient-border: oklch(0.58 0.15 var(--ambient-hue) / 0.12); - --ambient-tint: oklch(0.58 0.15 var(--ambient-hue) / 0.03); + /* Ambient (lower intensity on light). Redeclaring the colors here + resolves them against these channels on this element. */ + --ambient-l: 0.58; + --ambient-c: 0.15; + --ambient-glow-alpha: 0.06; + --ambient-border-alpha: 0.12; + --ambient-tint-alpha: 0.03; + --ambient-glow: oklch(var(--ambient-l) var(--ambient-c) var(--ambient-hue) / var(--ambient-glow-alpha)); + --ambient-border: oklch(var(--ambient-l) var(--ambient-c) var(--ambient-hue) / var(--ambient-border-alpha)); + --ambient-tint: oklch(var(--ambient-l) var(--ambient-c) var(--ambient-hue) / var(--ambient-tint-alpha)); /* Status (darkened for contrast on light surfaces) */ --status-success: oklch(0.55 0.18 155); diff --git a/docs/DESIGN-SYSTEM.md b/docs/DESIGN-SYSTEM.md index 667b43ca4..f0ed27633 100644 --- a/docs/DESIGN-SYSTEM.md +++ b/docs/DESIGN-SYSTEM.md @@ -504,15 +504,22 @@ root, and every `--ambient-*` token derives from that hue in OKLCH. | `--ambient-border` | 12 to 15% | Scrollbar thumb, active card border | | `--ambient-tint` | 3 to 4% | "Now Playing" background, noise tint | -`.preview-glow` crossfades over 2s on the `--ease-silk` curve, so hue shifts -drift rather than snap. +`.preview-glow` drifts to each new hue over 2s on the `--ease-silk` curve, so +hue shifts drift rather than snap. The drift runs on `--ambient-hue-drift`, a +registered `` that does not inherit, so each frame of it restyles the +halo element alone and never its subtree. **Discipline.** Hue extraction runs on a low-priority cadence, not the render -loop and not every frame. The CSS property update is one cheap `setProperty` -call; OKLCH interpolation happens in CSS. When no effect is active, -`--ambient-hue` falls back to `320` (purple), matching the primary accent. -Ambient reactivity is a progressive enhancement: the UI is correct and complete -without it. +loop and not every frame. A `:root` write restyles the whole document, so the +hue publishes at most once a second and only when it moves at least 4° +(`AmbientHuePublisher` in `src/ambient.rs`); the published value is unwrapped +so CSS always turns the short way around the wheel. Interpolation happens in +CSS. A surface that wants the drift rebuilds its color from the +`--ambient-l`, `--ambient-c` and `--ambient-*-alpha` channel tokens against +`--ambient-hue-drift`, because the composed `--ambient-*` colors have already +substituted the root hue. When no effect is active, `--ambient-hue` falls back +to `320` (purple), matching the primary accent. Ambient reactivity is a +progressive enhancement: the UI is correct and complete without it. ---