use alloc::borrow::ToOwned;
use alloc::rc::Rc;
use alloc::string::String;
use alloc::vec::Vec;
use core::cell::RefCell;
use core::fmt;
use half::f16;
use peniko::color::{AlphaColor, LinearSrgb};
use peniko::{
Blob, ImageAlphaType, ImageBrush, ImageData, ImageFormat, ImageQuality, ImageSampler,
};
use waterui_core::layout::Size;
use waterui_core::{Binding, Environment, SignalExt, View};
use waterui_graphics::{Scene2D, SceneContent, SceneInvalidator, SceneView};
use waterui_layout::{ContentMode, frame::Frame};
use crate::codec::{self, DecodedRgba};
use crate::scene::{self, ImageSceneContent, pixel_size, u32_to_f32};
pub use crate::codec::DecodePath;
#[derive(Debug, Clone)]
pub struct Image {
brush: ImageBrush,
resizable: bool,
content_mode: Option<ContentMode>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum Interpolation {
#[default]
Linear,
Nearest,
}
impl Interpolation {
const fn to_quality(self) -> ImageQuality {
match self {
Self::Linear => ImageQuality::Medium,
Self::Nearest => ImageQuality::Low,
}
}
}
fn tone_map_reinhard(component: f32) -> f32 {
let safe = component.max(0.0);
safe / (safe + 1.0)
}
fn rgba16f_to_srgb8(pixels: &[u8], high_dynamic_range: bool) -> Vec<u8> {
pixels
.as_chunks::<8>()
.0
.iter()
.flat_map(|texel| {
let component = |index: usize| {
let offset = index * 2;
f32::from(f16::from_le_bytes([texel[offset], texel[offset + 1]]))
};
let map = |component: f32| {
if high_dynamic_range {
tone_map_reinhard(component)
} else {
component
}
};
AlphaColor::<LinearSrgb>::new([
map(component(0)),
map(component(1)),
map(component(2)),
component(3).clamp(0.0, 1.0),
])
.to_rgba8()
.to_u8_array()
})
.collect()
}
fn size_in_bytes(format: ImageFormat, width: u32, height: u32) -> usize {
format
.size_in_bytes(width, height)
.expect("image dimensions must not overflow a byte count")
}
impl Image {
#[must_use]
pub fn new(pixels: Vec<u8>, width: u32, height: u32) -> Self {
assert_eq!(
pixels.len(),
size_in_bytes(ImageFormat::Rgba8, width, height),
"Pixel data length must be width * height * 4"
);
Self::from_rgba8(pixels, width, height)
}
#[must_use]
pub fn new_rgba16f(pixels: &[u8], width: u32, height: u32) -> Self {
Self::new_rgba16f_with_metadata(pixels, width, height, true)
}
#[must_use]
fn new_rgba16f_with_metadata(
pixels: &[u8],
width: u32,
height: u32,
high_dynamic_range: bool,
) -> Self {
assert_eq!(
pixels.len(),
size_in_bytes(ImageFormat::Rgba8, width, height) * 2,
"Pixel data length must be width * height * 8 for RGBA16F"
);
Self::from_rgba8(rgba16f_to_srgb8(pixels, high_dynamic_range), width, height)
}
fn from_rgba8(pixels: Vec<u8>, width: u32, height: u32) -> Self {
Self {
brush: ImageBrush {
image: ImageData {
data: Blob::from(pixels),
format: ImageFormat::Rgba8,
alpha_type: ImageAlphaType::Alpha,
width,
height,
},
sampler: ImageSampler::default()
.with_quality(Interpolation::default().to_quality()),
},
resizable: false,
content_mode: None,
}
}
#[must_use]
pub const fn interpolation(mut self, mode: Interpolation) -> Self {
self.brush.sampler.quality = mode.to_quality();
self
}
#[must_use]
pub const fn resizable(mut self) -> Self {
self.resizable = true;
self
}
#[must_use]
pub const fn content_mode(mut self, mode: ContentMode) -> Self {
self.content_mode = Some(mode);
self
}
#[must_use]
pub const fn dimensions(&self) -> (u32, u32) {
(self.width(), self.height())
}
#[must_use]
pub const fn width(&self) -> u32 {
self.brush.image.width
}
#[must_use]
pub const fn height(&self) -> u32 {
self.brush.image.height
}
pub fn from_encoded(data: &[u8]) -> Result<Self, String> {
codec::decode_to_rgba8(data).map(Self::from_decoded)
}
pub fn from_encoded_with_path(data: &[u8]) -> Result<(Self, DecodePath), String> {
codec::decode_to_rgba8_with_path(data)
.map(|(decoded, path)| (Self::from_decoded(decoded), path))
}
#[must_use]
pub fn stream_decoder(content_type: Option<&str>) -> ImageStreamDecoder {
ImageStreamDecoder::new(content_type)
}
#[cfg(feature = "gpu")]
#[expect(
clippy::future_not_send,
reason = "image rendering awaits the UI-local offscreen scene environment"
)]
pub async fn render_offscreen(
self,
runtime: &waterui_graphics::GpuRuntime,
config: waterui_graphics::OffscreenRenderConfig,
env: &mut Environment,
) -> Result<waterui_graphics::OffscreenRenderOutput, waterui_graphics::OffscreenRenderError>
{
SceneView::new(self.into_scene_content())
.into_gpu_surface()
.render_offscreen(runtime, config, env)
.await
}
fn from_decoded(decoded: DecodedRgba) -> Self {
match decoded.pixel_format {
waterkit_codec::DecodedPixelFormat::Rgba8UnormSrgb => {
Self::new(decoded.pixels, decoded.width, decoded.height)
}
waterkit_codec::DecodedPixelFormat::Rgba16Float => Self::new_rgba16f_with_metadata(
&decoded.pixels,
decoded.width,
decoded.height,
decoded.hdr,
),
other => {
panic!("Image::from_decoded: unsupported decoded pixel format: {other:?}");
}
}
}
fn into_scene_content(self) -> ImageSceneContent {
ImageSceneContent::new(self.brush, self.content_mode)
}
}
impl View for Image {
fn body(self, _env: &Environment) -> impl View {
let width = u32_to_f32(self.width());
let height = u32_to_f32(self.height());
let resizable = self.resizable;
let frame = Frame::new(SceneView::new(self.into_scene_content()));
if resizable {
frame
} else {
frame.width(width).height(height)
}
}
}
struct ReactiveImageState {
brush: RefCell<Option<ImageBrush>>,
dimensions: Binding<Option<(u32, u32)>>,
invalidator: RefCell<Option<SceneInvalidator>>,
}
impl fmt::Debug for ReactiveImageState {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ReactiveImageState")
.field("dimensions", &self.dimensions.get())
.finish_non_exhaustive()
}
}
impl ReactiveImageState {
fn publish(&self, brush: Option<ImageBrush>) {
self.dimensions.set(
brush
.as_ref()
.map(|brush| (brush.image.width, brush.image.height)),
);
*self.brush.borrow_mut() = brush;
if let Some(invalidator) = self.invalidator.borrow().as_ref() {
invalidator();
}
}
}
#[derive(Clone, Debug)]
pub struct ReactiveImageHandle {
state: Rc<ReactiveImageState>,
}
impl ReactiveImageHandle {
pub fn set(&self, image: Image) {
self.state.publish(Some(image.brush));
}
pub fn clear(&self) {
self.state.publish(None);
}
}
#[derive(Debug)]
pub struct ReactiveImage {
state: Rc<ReactiveImageState>,
resizable: bool,
content_mode: Option<ContentMode>,
}
impl ReactiveImage {
#[must_use]
pub const fn resizable(mut self) -> Self {
self.resizable = true;
self
}
#[must_use]
pub const fn content_mode(mut self, mode: ContentMode) -> Self {
self.content_mode = Some(mode);
self
}
}
impl View for ReactiveImage {
fn body(self, _env: &Environment) -> impl View {
let width = self
.state
.dimensions
.map(|dimensions| dimensions.map_or(0.0, |(width, _)| u32_to_f32(width)))
.computed();
let height = self
.state
.dimensions
.map(|dimensions| dimensions.map_or(0.0, |(_, height)| u32_to_f32(height)))
.computed();
let frame = Frame::new(SceneView::new(ReactiveImageSceneContent {
state: Rc::clone(&self.state),
content_mode: self.content_mode,
}));
if self.resizable {
frame
} else {
frame.width(width).height(height)
}
}
}
#[must_use]
pub fn reactive_image() -> (ReactiveImageHandle, ReactiveImage) {
let state = Rc::new(ReactiveImageState {
brush: RefCell::new(None),
dimensions: Binding::container(None),
invalidator: RefCell::new(None),
});
(
ReactiveImageHandle {
state: Rc::clone(&state),
},
ReactiveImage {
state,
resizable: false,
content_mode: None,
},
)
}
struct ReactiveImageSceneContent {
state: Rc<ReactiveImageState>,
content_mode: Option<ContentMode>,
}
impl fmt::Debug for ReactiveImageSceneContent {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ReactiveImageSceneContent")
.field("content_mode", &self.content_mode)
.finish_non_exhaustive()
}
}
impl SceneContent for ReactiveImageSceneContent {
fn build_scene(&mut self, target: &mut dyn Scene2D, width: f32, height: f32) -> bool {
if let Some(brush) = self.state.brush.borrow().as_ref() {
scene::draw(target, brush, self.content_mode, width, height);
}
false
}
fn intrinsic_size(&self) -> Option<Size> {
self.state
.dimensions
.get()
.and_then(|(width, height)| pixel_size(width, height))
}
fn set_invalidator(&mut self, invalidator: Option<SceneInvalidator>) {
*self.state.invalidator.borrow_mut() = invalidator;
}
}
impl Drop for ReactiveImageSceneContent {
fn drop(&mut self) {
self.state.invalidator.borrow_mut().take();
}
}
#[must_use]
pub fn image(pixels: Vec<u8>, width: u32, height: u32) -> Image {
Image::new(pixels, width, height)
}
#[derive(Debug, Clone)]
pub struct ImageStreamDecoder {
content_type: Option<String>,
bytes: Vec<u8>,
attempts: usize,
next_attempt_at: usize,
last_fingerprint: Option<u64>,
}
impl ImageStreamDecoder {
const FIRST_ATTEMPT_BYTES: usize = 24 * 1024;
const ATTEMPT_STEP_BYTES: usize = 96 * 1024;
const MAX_ATTEMPTS: usize = 10;
const MAX_BUFFER_BYTES: usize = 8 * 1024 * 1024;
#[must_use]
pub fn new(content_type: Option<&str>) -> Self {
Self {
content_type: content_type.map(ToOwned::to_owned),
bytes: Vec::new(),
attempts: 0,
next_attempt_at: Self::FIRST_ATTEMPT_BYTES,
last_fingerprint: None,
}
}
#[must_use]
pub fn push_chunk(&mut self, chunk: &[u8]) -> Option<Image> {
if chunk.is_empty() {
return None;
}
self.bytes.extend_from_slice(chunk);
let total_len = self.bytes.len();
if self.attempts >= Self::MAX_ATTEMPTS
|| total_len < self.next_attempt_at
|| total_len > Self::MAX_BUFFER_BYTES
|| !codec::is_progressive_candidate(self.content_type.as_deref(), &self.bytes)
{
return None;
}
self.attempts += 1;
self.next_attempt_at = total_len.saturating_add(Self::ATTEMPT_STEP_BYTES);
let decoded = codec::decode_progressive_frame(&self.bytes)?;
let fingerprint = frame_fingerprint(&decoded);
if self.last_fingerprint == Some(fingerprint) {
return None;
}
self.last_fingerprint = Some(fingerprint);
Some(Image::from_decoded(decoded))
}
pub fn finish(self) -> Result<Image, String> {
if self.bytes.is_empty() {
return Err(String::from("image response body was empty"));
}
Image::from_encoded(&self.bytes)
}
}
fn frame_fingerprint(decoded: &DecodedRgba) -> u64 {
let len = decoded.pixels.len();
if len == 0 {
return 0;
}
let first = u64::from(decoded.pixels[0]);
let mid = u64::from(decoded.pixels[len / 2]);
let last = u64::from(decoded.pixels[len - 1]);
(u64::from(decoded.width) << 32)
^ u64::from(decoded.height)
^ (u64::try_from(len).expect("image fingerprint length must fit in u64") << 8)
^ first
^ (mid << 16)
^ (last << 24)
}
#[cfg(test)]
mod tests {
use super::{
Image, Interpolation, Rc, ReactiveImageSceneContent, SceneContent as _, Size,
reactive_image, rgba16f_to_srgb8,
};
use half::f16;
use peniko::ImageQuality;
#[test]
fn reactive_image_replaces_frame_without_replacing_view() {
let (handle, _view) = reactive_image();
let content = ReactiveImageSceneContent {
state: Rc::clone(&handle.state),
content_mode: None,
};
assert_eq!(content.intrinsic_size(), None);
handle.set(Image::new(alloc::vec![0, 0, 0, 255], 1, 1));
assert_eq!(handle.state.dimensions.get(), Some((1, 1)));
assert_eq!(content.intrinsic_size(), Some(Size::new(1.0, 1.0)));
let displayed = {
let brush = handle.state.brush.borrow();
let brush = brush.as_ref().expect("published frame must be on display");
(brush.image.width, brush.image.height)
};
assert_eq!(displayed, (1, 1));
handle.clear();
assert_eq!(handle.state.dimensions.get(), None);
assert!(handle.state.brush.borrow().is_none());
assert_eq!(content.intrinsic_size(), None);
}
#[test]
fn interpolation_selects_the_sampling_quality() {
let image = Image::new(alloc::vec![0, 0, 0, 255], 1, 1);
assert_eq!(image.brush.sampler.quality, ImageQuality::Medium);
assert_eq!(
image
.interpolation(Interpolation::Nearest)
.brush
.sampler
.quality,
ImageQuality::Low
);
}
fn half_texel(components: [f32; 4]) -> alloc::vec::Vec<u8> {
components
.into_iter()
.flat_map(|component| f16::from_f32(component).to_le_bytes())
.collect()
}
#[test]
fn linear_float_pixels_are_srgb_encoded() {
let converted = rgba16f_to_srgb8(&half_texel([0.5, 0.5, 0.5, 1.0]), false);
assert_eq!(converted, alloc::vec![188, 188, 188, 255]);
}
#[test]
fn high_dynamic_range_pixels_are_tone_mapped_before_encoding() {
let converted = rgba16f_to_srgb8(&half_texel([1.0, 1.0, 1.0, 1.0]), true);
assert_eq!(converted, alloc::vec![188, 188, 188, 255]);
let converted = rgba16f_to_srgb8(&half_texel([1.0, 1.0, 1.0, 1.0]), false);
assert_eq!(converted, alloc::vec![255, 255, 255, 255]);
}
}