diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 67e3f4a0f..33cb672aa 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -3459,18 +3459,11 @@ class CursorModel with ChangeNotifier { throw FormatException('Invalid cursor pixel ratio: $pixelRatio'); } List colors = json.decode(evt['colors']); - var rgba = Uint8List.fromList(colors.map((s) => s as int).toList()); + final rgba = Uint8List.fromList(colors.map((s) => s as int).toList()); final ui.Image? image; - if (isWeb && - pixelRatio > 0 && + if (!isWeb && parent.target?.ffiModel.pi.platform == kPeerPlatformMacOS) { - // macOS cursors with density metadata use premultiplied alpha; older - // hosts send straight alpha. Web needs a straight-alpha resize source. - (rgba, image) = await _decodeWebMacCursor(rgba, width, height); - } else if (!isWeb && - pixelRatio == 0 && - parent.target?.ffiModel.pi.platform == kPeerPlatformMacOS) { - image = await _decodeLegacyMacCursor(rgba, width, height); + image = await _decodeMacCursor(rgba, width, height); } else { image = await img.decodeImageFromPixels( rgba, width, height, ui.PixelFormat.rgba8888); @@ -3491,10 +3484,10 @@ class CursorModel with ChangeNotifier { _updateCurData(); } - Future _decodeLegacyMacCursor( + Future _decodeMacCursor( Uint8List rgba, int width, int height) async { - // Old macOS packets are straight alpha. Premultiply a copy for native - // ui.Image, retaining the original colors for the separate byte cache. + // macOS packets keep straight alpha, regardless of density metadata. + // Premultiply only for native ui.Image; the PNG cache needs straight colors. final source = img2.Image.fromBytes( width: width, height: height, bytes: rgba.buffer, order: img2.ChannelOrder.rgba); for (final pixel in source) { @@ -3507,30 +3500,6 @@ class CursorModel with ChangeNotifier { source.getBytes(), width, height, ui.PixelFormat.rgba8888); } - Future<(Uint8List, ui.Image)> _decodeWebMacCursor( - Uint8List rgba, int width, int height) async { - final source = img2.Image.fromBytes( - width: width, height: height, bytes: rgba.buffer, order: img2.ChannelOrder.rgba); - for (final pixel in source) { - final alpha = pixel.a; - if (alpha == 0) continue; - final maxChannel = pixel.maxChannelValue; - // RGB and alpha rounding in capture can differ by one channel value. - pixel.r = min(maxChannel, (pixel.r * maxChannel / alpha).round()); - pixel.g = min(maxChannel, (pixel.g * maxChannel / alpha).round()); - pixel.b = min(maxChannel, (pixel.b * maxChannel / alpha).round()); - } - // Web ImageDescriptor.raw treats RGBA as straight alpha. Decode a PNG so - // both the painted cursor and the resize source use the correct colors. - final codec = await ui.instantiateImageCodec( - Uint8List.fromList(img2.encodePng(source))); - try { - return (source.getBytes(), (await codec.getNextFrame()).image); - } finally { - codec.dispose(); - } - } - Future _updateCache( Uint8List rgba, ui.Image image, diff --git a/flutter/test/cursor_native_alpha_test.dart b/flutter/test/cursor_native_alpha_test.dart index 916765981..a871db530 100644 --- a/flutter/test/cursor_native_alpha_test.dart +++ b/flutter/test/cursor_native_alpha_test.dart @@ -67,8 +67,8 @@ void main() { for (final testCase in <(String, String?, List)>[ (kPeerPlatformMacOS, null, [64, 32, 16, 128]), (kPeerPlatformMacOS, '0', [64, 32, 16, 128]), - (kPeerPlatformMacOS, '1', [32, 16, 8, 128]), - (kPeerPlatformMacOS, '2', [32, 16, 8, 128]), + (kPeerPlatformMacOS, '1', [64, 32, 16, 128]), + (kPeerPlatformMacOS, '2', [64, 32, 16, 128]), (kPeerPlatformLinux, '0', [32, 16, 8, 128]), ]) { for (final dpr in [1.0, 2.0]) { diff --git a/flutter/test/cursor_web_test.dart b/flutter/test/cursor_web_test.dart index 0d92e6fc4..e85207bb9 100644 --- a/flutter/test/cursor_web_test.dart +++ b/flutter/test/cursor_web_test.dart @@ -225,9 +225,9 @@ Future _checkPolicy(WidgetTester tester, String style, double dpr) async { void _alphaTests() { for (final (density, pixel, expected) in [ - (1.0, [128, 128, 128, 128], [255, 255, 255, 128]), - (2.0, [128, 64, 32, 128], [255, 128, 64, 128]), - (1.0, [113, 56, 28, 112], [255, 128, 64, 112]), + (1.0, [255, 255, 255, 128], [255, 255, 255, 128]), + (2.0, [255, 128, 64, 128], [255, 128, 64, 128]), + (1.0, [255, 128, 64, 112], [255, 128, 64, 112]), (1.0, [0, 0, 0, 0], [0, 0, 0, 0]), (1.0, [255, 255, 255, 255], [255, 255, 255, 255]), // Old macOS hosts send straight alpha without density metadata. diff --git a/src/platform/macos.rs b/src/platform/macos.rs index b533f6d57..fe44009d8 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -677,9 +677,10 @@ fn unsafe_get_cursor_data(hcursor: u64) -> ResultType { let g: f64 = msg_send![color, greenComponent]; let b: f64 = msg_send![color, blueComponent]; let a: f64 = msg_send![color, alphaComponent]; - colors.push((r * a * 255.).round() as _); - colors.push((g * a * 255.).round() as _); - colors.push((b * a * 255.).round() as _); + // Keep straight RGBA on the wire for older Web/Sciter receivers. + colors.push((r * 255.) as _); + colors.push((g * 255.) as _); + colors.push((b * 255.) as _); colors.push((a * 255.) as _); } } diff --git a/src/platform/macos/cursor.rs b/src/platform/macos/cursor.rs index 3d04b45a0..3d50f2cd0 100644 --- a/src/platform/macos/cursor.rs +++ b/src/platform/macos/cursor.rs @@ -107,7 +107,7 @@ pub(super) unsafe fn data(cursor: id, id: u64, scale: f64) -> ResultType ResultType Vec { + // AppKit renders premultiplied pixels, but macOS cursor packets have always + // used straight alpha. Density metadata does not negotiate a new format. + const MAX_CHANNEL: u16 = u8::MAX as u16; + let mut colors = pixels.to_vec(); + for pixel in colors.chunks_exact_mut(CHANNELS) { + let alpha = u16::from(pixel[CHANNELS - 1]); + if alpha == 0 { + continue; + } + for channel in &mut pixel[..CHANNELS - 1] { + *channel = + ((u16::from(*channel) * MAX_CHANNEL + alpha / 2) / alpha).min(MAX_CHANNEL) as u8; + } + } + colors +} + #[cfg(test)] mod tests { use super::*; @@ -168,6 +186,55 @@ mod tests { }); } + #[test] + fn retina_cursor_keeps_straight_alpha_for_legacy_receivers() { + const SIDE: usize = 4; + const SCALE: f64 = 2.0; + const PREMULTIPLIED: [[u8; CHANNELS]; SIDE] = [ + [128, 128, 128, 128], + [64, 32, 16, 128], + [240, 100, 20, 255], + [0, 0, 0, 0], + ]; + const STRAIGHT: [[u8; CHANNELS]; SIDE] = [ + [255, 255, 255, 128], + [128, 64, 32, 128], + [240, 100, 20, 255], + [0, 0, 0, 0], + ]; + autoreleasepool(|| unsafe { + let logical = NSSize::new(SIDE as f64 / SCALE, SIDE as f64 / SCALE); + let image: id = msg_send![class!(NSImage), alloc]; + let image = StrongPtr::new(msg_send![image, initWithSize: logical]); + let rep = bitmap(NSSize::new(SIDE as f64, SIDE as f64)).unwrap(); + let pixels: Vec = (0..SIDE * SIDE) + .flat_map(|index| PREMULTIPLIED[index % SIDE]) + .collect(); + let buffer: *mut u8 = msg_send![*rep, bitmapData]; + ptr::copy_nonoverlapping(pixels.as_ptr(), buffer, pixels.len()); + let (): () = msg_send![*rep, setSize: logical]; + let (): () = msg_send![*image, addRepresentation: *rep]; + let cursor: id = msg_send![class!(NSCursor), alloc]; + let cursor = StrongPtr::new( + msg_send![cursor, initWithImage: *image hotSpot: NSPoint::new(1.0, 1.0)], + ); + let result = data(*cursor, 1, SCALE).unwrap(); + // Older Sciter receivers encode the received bytes directly as PNG. + let mut png = Vec::new(); + repng::encode( + &mut png, + result.width as _, + result.height as _, + &result.colors, + ) + .unwrap(); + let decoded = image::load_from_memory(&png).unwrap().to_rgba8(); + for (index, pixel) in decoded.pixels().enumerate() { + assert_eq!(pixel.0, STRAIGHT[index % SIDE]); + } + }); + } + #[test] fn cursor_cache_changes_with_display_scale() { assert_ne!(cache_id(123, 1.0), cache_id(123, 2.0)); diff --git a/src/ui/remote.rs b/src/ui/remote.rs index a90a74b92..2f36f5d81 100644 --- a/src/ui/remote.rs +++ b/src/ui/remote.rs @@ -1,10 +1,7 @@ use std::{ collections::HashMap, ops::{Deref, DerefMut}, - sync::{ - atomic::{AtomicBool, AtomicUsize, Ordering}, - Arc, Mutex, RwLock, - }, + sync::{atomic::AtomicUsize, Arc, Mutex, RwLock}, }; use sciter::{ @@ -31,8 +28,6 @@ use crate::{ type Video = AssetPtr; -mod cursor; - lazy_static::lazy_static! { static ref VIDEO: Arc>> = Default::default(); } @@ -44,8 +39,6 @@ lazy_static::lazy_static! { pub struct SciterHandler { element: Arc>>, close_state: HashMap, - // The I/O session clones its UI handler, so share the peer's pixel format. - peer_is_macos: Arc, } impl SciterHandler { @@ -129,7 +122,7 @@ impl SciterHandler { impl InvokeUiSession for SciterHandler { fn set_cursor_data(&self, cd: CursorData) { - let mut colors = cursor::png_colors(&cd, self.peer_is_macos.load(Ordering::Relaxed)); + let mut colors = hbb_common::compress::decompress(&cd.colors); if colors.iter().filter(|x| **x != 0).next().is_none() { log::info!("Fix transparent"); // somehow all 0 images shows black rect, here is a workaround @@ -308,8 +301,6 @@ impl InvokeUiSession for SciterHandler { } fn set_peer_info(&self, pi: &PeerInfo) { - self.peer_is_macos - .store(pi.platform == "Mac OS", Ordering::Relaxed); let mut pi_sciter = Value::map(); pi_sciter.set_item("username", pi.username.clone()); pi_sciter.set_item("hostname", pi.hostname.clone()); diff --git a/src/ui/remote/cursor.rs b/src/ui/remote/cursor.rs deleted file mode 100644 index 69e5d5187..000000000 --- a/src/ui/remote/cursor.rs +++ /dev/null @@ -1,79 +0,0 @@ -use base::message_proto::CursorData; - -const CHANNELS: usize = 4; -const ALPHA: usize = CHANNELS - 1; -const MAX_CHANNEL: u16 = u8::MAX as u16; - -pub(super) fn png_colors(cursor: &CursorData, is_macos: bool) -> Vec { - let mut colors = hbb_common::compress::decompress(&cursor.colors); - // New macOS packets are premultiplied for Flutter, but PNG stores straight - // colors. Legacy macOS packets (scale zero) already contain straight RGBA. - if !(is_macos && cursor.scale > 0.0) { - return colors; - } - for pixel in colors.chunks_exact_mut(CHANNELS) { - let alpha = u16::from(pixel[ALPHA]); - if alpha == 0 { - continue; - } - for channel in &mut pixel[..ALPHA] { - // Capture can round RGB and alpha differently by one channel value. - *channel = - ((u16::from(*channel) * MAX_CHANNEL + alpha / 2) / alpha).min(MAX_CHANNEL) as u8; - } - } - colors -} - -#[cfg(test)] -mod tests { - use super::*; - - const STRAIGHT: [u8; 16] = [ - 255, 255, 255, 128, 128, 64, 32, 128, 240, 100, 20, 255, 0, 0, 0, 0, - ]; - const PREMULTIPLIED: [u8; 16] = [ - 128, 128, 128, 128, 64, 32, 16, 128, 240, 100, 20, 255, 0, 0, 0, 0, - ]; - - fn encode_packet(pixels: &[u8], scale: f64, is_macos: bool) -> Vec { - let cursor = CursorData { - width: 4, - height: 1, - colors: hbb_common::compress::compress(pixels).into(), - scale, - ..Default::default() - }; - let colors = png_colors(&cursor, is_macos); - let mut png = Vec::new(); - repng::encode(&mut png, cursor.width as _, cursor.height as _, &colors).unwrap(); - assert_eq!(hbb_common::compress::decompress(&cursor.colors), pixels); - image::load_from_memory_with_format(&png, image::ImageFormat::Png) - .unwrap() - .to_rgba8() - .into_raw() - } - - #[test] - fn macos_density_packets_encode_straight_png_colors() { - for scale in [1.0, 2.0] { - let actual = encode_packet(&PREMULTIPLIED, scale, true); - for (got, expected) in actual - .chunks_exact(CHANNELS) - .zip(STRAIGHT.chunks_exact(CHANNELS)) - { - assert_eq!(got[ALPHA], expected[ALPHA]); - for channel in 0..ALPHA { - assert!(got[channel].abs_diff(expected[channel]) <= 1, "{actual:?}"); - } - } - } - } - - #[test] - fn legacy_and_non_macos_packets_keep_their_colors() { - for (scale, is_macos) in [(0.0, true), (0.0, false), (2.0, false)] { - assert_eq!(encode_packet(&STRAIGHT, scale, is_macos), STRAIGHT); - } - } -}