diff --git a/Cargo.lock b/Cargo.lock index b3f810f..1cbc773 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2206,15 +2206,13 @@ dependencies = [ name = "omni-led-api" version = "0.1.0" dependencies = [ + "image", "log", "prost", - "serde", - "serde_json", "tokio", "tokio-stream", "tonic", "tonic-build", - "ureq", ] [[package]] diff --git a/config/scripts.lua b/config/scripts.lua index a9acc30..028ae6c 100644 --- a/config/scripts.lua +++ b/config/scripts.lua @@ -4,19 +4,19 @@ local function volume() Text { text = AUDIO.IsMuted and 'Muted' or AUDIO.Volume, font_size = 24, - text_offset = 'AutoUpper', + text_offset = 1, position = { x = 0, y = 0 }, size = { width = SCREEN.Width, height = SCREEN.Height / 2 }, }, Text { text = AUDIO.Name, scrolling = true, + repeats = 'Once', position = { x = 0, y = SCREEN.Height / 2 }, size = { width = SCREEN.Width, height = SCREEN.Height / 2 }, }, }, duration = 2000, - repeats = 'Once', } end @@ -55,7 +55,6 @@ local function spotify() }, }, duration = SPOTIFY_DURATION, - repeats = 'ForDuration', } end @@ -65,14 +64,14 @@ local function clock() Text { text = string.format("%02d", CLOCK.Hours), font_size = 50, - text_offset = 'AutoUpper', + text_offset = 1, position = { x = 10, y = 0 }, size = { width = SCREEN.Width / 2, height = SCREEN.Height - 3 }, }, Text { text = string.format("%02d", CLOCK.Minutes), font_size = 37, - text_offset = 'AutoUpper', + text_offset = 1, position = { x = SCREEN.Width / 2 + 3, y = 0 }, size = { width = 54, height = 26 }, }, @@ -113,7 +112,7 @@ local function weather() Text { text = value, font_size = 30, - text_offset = 'AutoUpper', + text_offset = 1, position = { x = SCREEN.Height, y = 0 }, size = { width = SCREEN.Height * 2, height = SCREEN.Height * 2 / 3 }, }, diff --git a/docs/scripting_reference.md b/docs/scripting_reference.md index bdaff53..63a6e08 100644 --- a/docs/scripting_reference.md +++ b/docs/scripting_reference.md @@ -153,6 +153,25 @@ > > > > Load for a system-installed font. +--- + +> ### `FontSize` +> +> Specifies the text font size inside of `Text` widget. +> +> > `Auto` +> > +> > Calculate font size to fit any text in the widget's height. +> +> > `AutoUpper` +> > +> > Calculate font size to fit any text that doesn't have any "descendants". Useful for text that +> > consists only of uppercase characters or numbers. +> +> > `n: integer` +> > +> > Set font size to be exactly `n`, regardless of widget size. + --- > ### `FontStretch` @@ -215,6 +234,44 @@ --- +> ### `ImageFormat` +> +> Image format. +> +> > `Avif` +> +> > `Bmp` +> +> > `Dds` +> +> > `Farbfeld` +> +> > `Gif` +> +> > `Hdr` +> +> > `Ico` +> +> > `Jpeg` +> +> > `OpenExr` +> +> > `Pcx` +> +> > `Png` +> +> > `Pnm` +> +> > `Qoi` +> +> > `Tga` +> +> > `Tiff` +> +> > `Webp` + +--- + > ### `LogLevel` > > Log level filter, selecting one value will also activate all values above it, e.g. enabling @@ -261,38 +318,20 @@ --- -> ### `Offset` -> -> Specifies the text offset from the bottom of a `Text` widget. -> -> > `Auto` -> > -> > Calculate offset to fit any text in the widget's height. -> -> > `AutoUpper` -> > -> > Calculate offset to fit any text that doesn't have any "descendants". Useful for text that -> > consists only of uppercase characters or numbers. -> -> > `n: integer` -> > -> > Offset exactly by `n` pixels, regardless of font size. - ---- - > ### `Repeat` > -> Repeat strategy for a widget. Currently, this only applies to scrolling text. +> Repeat strategy for a widget. Applies to scrolling text and animated images. > > > `Once` > > -> > Repeats the script until the text is fully scrolled, even if it takes longer than the duration -> > specified for layout. This way the entire text is displayed exactly once. +> > Repeats the script until the animation is finished, even if it takes longer than the duration +> > specified for layout. This way the entire animation is displayed exactly once. > > > `ForDuation` > > -> > Repeats the script for the time of its duration. This will scroll text for an exact duration, -> > but can cut off mid-scrolling if the time runs out. +> > Repeats the script for the time of its duration. This will run the animation for an exact +> > duration, +> > but can cut off mid-animation if the time runs out. ## Functions @@ -442,6 +481,20 @@ --- +> ### `ImageData` +> +> Contains image bytes and format +> +> > `format`: `ImageFormat` +> > +> > Image format, required to properly load image bytes. +> +> > `bytes`: `[byte]` +> > +> > Image bytes stored in format specified by the `format` property. + +--- + > ### `Layout` > > Represents a user-defined script that runs on specific events and creates a layout to be rendered. @@ -477,12 +530,6 @@ > > How many milliseconds can the layout be shown on the screen before it's allowed to be > > overridden. Higher priority layouts can always override lower priority, regardless of the > > remaining duration. -> -> > `repeats: Repeat` -> > -> > _Optional_. Default: `Once`. -> > -> > Specifies the repeat strategy, which is only applicable to scrolling text for now. --- @@ -516,23 +563,6 @@ --- -> ### `OledImage` -> -> Represents a black-and-white image. -> -> > `size`: `Size` -> > -> > Source image size. To adjust image size on screen, set the appropriate widget size to the -> > desired value. -> -> > `bytes`: `[byte]` -> > -> > Row-major black-and-white image data with one byte per pixel. All non-zero values will result -> > in the pixels being on. -> > `size.width * size.height` must equal the length of the `bytes` array. - ---- - > ### `Point` > > Represents a coordinate in a 2D space with an origin `(0, 0)` in the top left corner of the @@ -646,23 +676,6 @@ --- - #[mlua(transform(from_hex))] - pub vendor_id: u16, - #[mlua(transform(from_hex))] - pub product_id: u16, - #[mlua(transform(from_hex))] - pub interface: u8, - #[mlua(transform(from_hex))] - pub alternate_setting: u8, - #[mlua(transform(from_hex))] - pub request_type: u8, - #[mlua(transform(from_hex))] - pub request: u8, - #[mlua(transform(from_hex))] - pub value: u16, - #[mlua(transform(from_hex))] - pub index: u16, - > ### `USBSettings` > > Configuration for a USB device. All fields relate to the USB configuration. @@ -747,10 +760,57 @@ All widgets have the following common attributes in addition to widget-specific > > A widget that displays an image. > -> > `image`: `OledImage` +> > `image`: `ImageData` > > > > The image data to display on the screen. > > This image will be scaled from its original size to the dimensions of the widget. +> +> > `animated`: `bool` +> > +> > _Optional_. Default: `false` +> > +> > Specifies if the image should be animated. Unless set to `true`, event with supported image +> > formats, only a static image will be rendered. +> +> > `animation_group`: `integer` +> > +> > _Optional_. Default: `0` +> > +> > Sets the animation group for the widget. All animations within a single animation group are +> > synced, +> > except for the default group `0`, where all animations are independent. +> +> > `animation_ticks_delay`: `integer` +> > +> > _Optional_. Default: No value +> > +> > Overrides [global animation setting](settings.md#animation) for this widget. Applies only for +> > `animated` images. +> > +> > **Changing this value after initially setting it for a given widget is undefined behaviour.** +> +> > `animation_ticks_rate`: `integer` +> > +> > _Optional_. Default: No value +> > +> > Overrides [global animation setting](settings.md#animation) for this widget. Applies only for +> > `animated` images. +> > +> > **Changing this value after initially setting it for a given widget is undefined behaviour.** +> +> > `repeats: Repeat` +> > +> > _Optional_. Default: `ForDuration`. +> > +> > Specifies the repeat strategy, applies only for animated images. +> +> > `threshold`: `integer` +> > +> > _Optional_. Default: `128` +> > +> > Specifies the threshold from range `[0, 255]` used to convert image to a black and white image. +> > Light values below the threshold will be converted to black, and values above the threshold will +> > be converted to white. --- @@ -768,14 +828,46 @@ All widgets have the following common attributes in addition to widget-specific > > > > Specifies if the text should scroll if it is too long to fit within the widget's width. > -> > `font_size`: `integer` +> > `animation_group`: `integer` > > -> > _Optional_. Default: Calculated to fit within the widget's height. +> > _Optional_. Default: `0` > > -> > Sets the font size of the text. +> > Sets the animation group for the widget. All animations within a single animation group are +> > synced, +> > except for the default group `0`, where all animations are independent. > -> > `text_offset`: `Offset` +> > `animation_ticks_delay`: `integer` +> > +> > _Optional_. Default: No value +> > +> > Overrides [global animation setting](settings.md#animation) for this widget. Applies only for +> > `scrolling` text. +> > +> > **Changing this value after initially setting it for a given widget is undefined behaviour.** +> +> > `animation_ticks_rate`: `integer` +> > +> > _Optional_. Default: No value +> > +> > Overrides [global animation setting](settings.md#animation) for this widget. Applies only for +> > `scrolling` text. +> > +> > **Changing this value after initially setting it for a given widget is undefined behaviour.** +> +> > `font_size`: `FontSize` > > > > _Optional_. Default: `"Auto"`. > > +> > Sets the font size of the text. +> +> > `repeats: Repeat` +> > +> > _Optional_. Default: `ForDuration`. +> > +> > Specifies the repeat strategy, applies only for scrolling text. +> +> > `text_offset`: `integer` +> > +> > _Optional_. Default: Calculated automatically based on the `font_size`. +> > > > Determines the offset of the text from the bottom of the widget. \ No newline at end of file diff --git a/docs/settings.md b/docs/settings.md index 4e335a1..1203cb9 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -1,17 +1,43 @@ # Settings You can fine tune behaviour of the program using the [settings file](../config/settings.lua). -All the top-level properties described below are optional and will be set to default, should any of them be missing. +All the top-level properties described below are optional and will be set to default, should any of +them be missing. ## Available Settings +- [Animation](#animation) - [Font](#font) - [Log Level](#log-level) - [Keyboard](#keyboard) -- [Text Scrolling](#text-scrolling) - [Server Port](#server-port) - [Update Interval](#update-interval-tick-duration) +> ### Animation +> +> Animation settings apply to scrolling text or animated images. +> +> > `animation_ticks_delay`: `integer` +> > +> > Number of [ticks](#update-interval-tick-duration) after which the animation will start to +> > advance. +> > +> > _Optional_. Default: `8` +> +> > `animation_ticks_rate`: `integer` +> > +> > Number of [ticks](#update-interval-tick-duration) between consecutive animation steps. +> > +> > _Optional_. Default: `2` +> +> > Example `settings.lua` that sets scroll delay and repeat delay. +> > ```lua +> > Settings { +> > animation_ticks_delay = 4, +> > animation_ticks_rate = 1, +> > } +> > ``` + > ### Font > > > `font`: [`FontSelector`](scripting_reference.md#fontselector) @@ -46,7 +72,7 @@ All the top-level properties described below are optional and will be set to def > > Settings { > > font = { > > System = { -> > names = ['FiraMono', 'Monospace'], +> > names = {'FiraMono', 'Monospace'}, > > style = 'Normal', > > weight = 'Bold', > > stretch = 'Condensed', @@ -93,29 +119,6 @@ All the top-level properties described below are optional and will be set to def > > } > > ``` -> ### Text Scrolling -> -> > `text_ticks_scroll_delay`: `integer` -> > -> > Number of [ticks](#update-interval-tick-duration) after which text will start scrolling if it -> > does not fit the screen. -> > -> > _Optional_. Default: `8` -> -> > `text_ticks_repeat_delay`: `integer` -> > -> > Number of [ticks](#update-interval-tick-duration) between consecutive text scrolls. -> > -> > _Optional_. Default: `2` -> -> > Example `settings.lua` that sets scroll delay and repeat delay. -> > ```lua -> > Settings { -> > text_ticks_scroll_delay = 4, -> > text_ticks_repeat_delay = 1, -> > } -> > ``` - > ### Server Port > > > `server_port`: `integer` @@ -141,7 +144,7 @@ All the top-level properties described below are optional and will be set to def > > ``` > ### Update interval (Tick Duration) -> +> > > `update_interval`: `integer` > > > > This setting will define how ofter the server will process events and render updates on the diff --git a/omni-led-api/Cargo.toml b/omni-led-api/Cargo.toml index df2334e..cca2283 100644 --- a/omni-led-api/Cargo.toml +++ b/omni-led-api/Cargo.toml @@ -8,14 +8,12 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +image = "0.25" log = "0.4" prost = "0.13" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tokio-stream = "0.1" tonic = "0.12" -ureq = { version = "2.4", features = ["json"] } [build-dependencies] tonic-build = "0.12" \ No newline at end of file diff --git a/omni-led-api/proto/plugin.proto b/omni-led-api/proto/plugin.proto index ed490f4..4607310 100644 --- a/omni-led-api/proto/plugin.proto +++ b/omni-led-api/proto/plugin.proto @@ -22,7 +22,7 @@ message Field { string f_string = 4; Array f_array = 5; Table f_table = 6; - Image f_image = 7; + ImageData f_image_data = 7; } } @@ -34,10 +34,29 @@ message Table { map items = 1; } -message Image { - int64 width = 1; - int64 height = 2; - bytes data = 3; +message ImageData { + ImageFormat format = 1; + bytes data = 2; +} + +enum ImageFormat { + IMAGE_FORMAT_UNKNOWN = 0; + IMAGE_FORMAT_PNG = 1; + IMAGE_FORMAT_JPEG = 2; + IMAGE_FORMAT_GIF = 3; + IMAGE_FORMAT_WEBP = 4; + IMAGE_FORMAT_PNM = 5; + IMAGE_FORMAT_TIFF = 6; + IMAGE_FORMAT_TGA = 7; + IMAGE_FORMAT_DDS = 8; + IMAGE_FORMAT_BMP = 9; + IMAGE_FORMAT_ICO = 10; + IMAGE_FORMAT_HDR = 11; + IMAGE_FORMAT_OPEN_EXR = 12; + IMAGE_FORMAT_FARBFELD = 13; + IMAGE_FORMAT_AVIF = 14; + IMAGE_FORMAT_QOI = 15; + IMAGE_FORMAT_PCX = 16; } message LogData { diff --git a/omni-led-api/src/types.rs b/omni-led-api/src/types.rs index 0e267c9..a866323 100644 --- a/omni-led-api/src/types.rs +++ b/omni-led-api/src/types.rs @@ -142,4 +142,52 @@ impl> Into for Vec { } // Image values -into_field!(Image, field::Field::FImage); +into_field!(ImageData, field::Field::FImageData); + +impl From for ImageFormat { + fn from(value: image::ImageFormat) -> Self { + match value { + image::ImageFormat::Png => ImageFormat::Png, + image::ImageFormat::Jpeg => ImageFormat::Jpeg, + image::ImageFormat::Gif => ImageFormat::Gif, + image::ImageFormat::WebP => ImageFormat::Webp, + image::ImageFormat::Pnm => ImageFormat::Pnm, + image::ImageFormat::Tiff => ImageFormat::Tiff, + image::ImageFormat::Tga => ImageFormat::Tga, + image::ImageFormat::Dds => ImageFormat::Dds, + image::ImageFormat::Bmp => ImageFormat::Bmp, + image::ImageFormat::Ico => ImageFormat::Ico, + image::ImageFormat::Hdr => ImageFormat::Hdr, + image::ImageFormat::OpenExr => ImageFormat::OpenExr, + image::ImageFormat::Farbfeld => ImageFormat::Farbfeld, + image::ImageFormat::Avif => ImageFormat::Avif, + image::ImageFormat::Qoi => ImageFormat::Qoi, + image::ImageFormat::Pcx => ImageFormat::Pcx, + _ => ImageFormat::Unknown, + } + } +} + +impl Into for ImageFormat { + fn into(self) -> image::ImageFormat { + match self { + ImageFormat::Unknown => todo!(), + ImageFormat::Png => image::ImageFormat::Png, + ImageFormat::Jpeg => image::ImageFormat::Jpeg, + ImageFormat::Gif => image::ImageFormat::Gif, + ImageFormat::Webp => image::ImageFormat::WebP, + ImageFormat::Pnm => image::ImageFormat::Pnm, + ImageFormat::Tiff => image::ImageFormat::Tiff, + ImageFormat::Tga => image::ImageFormat::Tga, + ImageFormat::Dds => image::ImageFormat::Dds, + ImageFormat::Bmp => image::ImageFormat::Bmp, + ImageFormat::Ico => image::ImageFormat::Ico, + ImageFormat::Hdr => image::ImageFormat::Hdr, + ImageFormat::OpenExr => image::ImageFormat::OpenExr, + ImageFormat::Farbfeld => image::ImageFormat::Farbfeld, + ImageFormat::Avif => image::ImageFormat::Avif, + ImageFormat::Qoi => image::ImageFormat::Qoi, + ImageFormat::Pcx => image::ImageFormat::Pcx, + } + } +} diff --git a/omni-led-applications/images/README.md b/omni-led-applications/images/README.md index 2ea0144..47ad677 100644 --- a/omni-led-applications/images/README.md +++ b/omni-led-applications/images/README.md @@ -1,7 +1,7 @@ # Images -Images application reads images from disk and converts them to a black and white image before -sending it to OmniLED server. +Images application reads images from disk and sends them to OmniLED. It supports both static and +animated images. ## Running @@ -21,13 +21,8 @@ Images expects two arguments _This is a positional argument and shall always be specified as a first argument._ - `` - Path to an image file on disk. _This is a positional argument and shall always be specified as a second argument._ - - `-f`/`--format` - Image extension used as a hint for loading images when path doesn't - contain an extension. - - `-t`/`--threshold` - Threshold that will be used when converting the image to black and - white. Values with brightness lower than threshold will be black, and above or equal to - threshold will be white. - Range: [0, 255]. - Default: 128 + - `-f`/`--format` - Image extension used as a hint for loading images when the format cannot + automatically be deduced from the file contents. ### Example @@ -38,8 +33,8 @@ load_app { path = get_default_path('images'), args = { '--address', SERVER.Address, - '--image', 'MyImage /path/to/my_image --format jpg --threshold 77', - '--image', 'MyOtherImage "C:\\path\\to\\other image.png" --threshold 159', + '--image', 'MyImage /path/to/my_gif --format gif', + '--image', 'MyOtherImage "C:\\path\\to\\other image.png"', } } ``` @@ -51,6 +46,6 @@ arguments. `IMAGES`: table -- ``: `OLEDImage`, +- ``: `Image`, ... -- ``: `OLEDImage`, \ No newline at end of file +- ``: `Image`, \ No newline at end of file diff --git a/omni-led-applications/images/src/main.rs b/omni-led-applications/images/src/main.rs index 060a43a..cdeb620 100644 --- a/omni-led-applications/images/src/main.rs +++ b/omni-led-applications/images/src/main.rs @@ -17,11 +17,10 @@ */ use clap::{ArgAction, Parser}; -use image::{ImageBuffer, ImageFormat, Luma}; +use image::guess_format; use log::{debug, error}; use omni_led_api::plugin::Plugin; -use omni_led_api::types::{Image, Table}; -use std::path::{Path, PathBuf}; +use omni_led_api::types::{ImageData, ImageFormat, Table}; const NAME: &str = "IMAGES"; @@ -29,62 +28,64 @@ const NAME: &str = "IMAGES"; async fn main() { let options = Options::parse(); + // TODO verify that all image names are unique + let mut plugin = Plugin::new(NAME, &options.address).await.unwrap(); - let images = load_images(options.image_options); + let images = load_images(options.images); plugin.update(images).await.unwrap(); } -fn load_images(image_options: Vec) -> Table { +fn load_images(image_options: Vec) -> Table { let mut table = Table::default(); for option in image_options { - let image = match load_image(&option.path, option.threshold, option.format) { - Ok(image) => { - debug!("Loaded {:?}", option); - image + let (format, bytes) = match load_image(&option.path, &option.format) { + Ok((format, bytes)) => { + debug!("Loaded image {:?}", option); + + let format: ImageFormat = format.into(); + (format, bytes) } Err(err) => { error!("Failed to load {:?}: {}", option, err); continue; } }; - table.items.insert(option.name, image.into()); + + table.items.insert( + option.name, + ImageData { + format: format as i32, + data: bytes, + } + .into(), + ); } table } fn load_image( - path: &Path, - threshold: u8, - format: Option, -) -> Result> { + path: &str, + format: &Option, +) -> Result<(image::ImageFormat, Vec), Box> { let bytes = std::fs::read(path)?; - let image = match format { - Some(format) => image::load_from_memory_with_format(&bytes, format)?, - None => image::load_from_memory(&bytes)?, + let format = match &format { + Some(format) => match image::ImageFormat::from_extension(format) { + Some(format) => format, + None => { + return Err(format!("Unknown image format '{:?}'", format).into()); + } + }, + None => guess_format(&bytes)?, }; - let image = image.into_luma8(); - - let image: ImageBuffer, Vec> = - ImageBuffer::from_fn(image.width(), image.height(), |x, y| { - let pixel = image.get_pixel(x, y); - if pixel[0] >= threshold { - Luma([255]) - } else { - Luma([0]) - } - }); + // Test if image actually loads with provided or guessed format + let _ = image::load_from_memory_with_format(&bytes, format)?; - let image = Image { - width: image.width() as i64, - height: image.height() as i64, - data: image.into_raw(), - }; - Ok(image) + Ok((format, bytes)) } #[derive(Parser, Debug)] @@ -93,19 +94,11 @@ struct Options { #[clap(short, long)] address: String, - #[clap(short = 'i', long = "image", action = ArgAction::Append, value_parser = parse_image_options)] - image_options: Vec, -} - -#[derive(Debug, Clone)] -struct ImageLoadSettings { - name: String, - path: PathBuf, - threshold: u8, - format: Option, + #[clap(short = 'i', long = "image", action = ArgAction::Append, value_parser = parse_options)] + images: Vec, } -#[derive(Parser, Debug)] +#[derive(Parser, Debug, Clone)] #[command(author, version, about)] struct ImageOptions { #[clap(index = 1)] @@ -114,28 +107,18 @@ struct ImageOptions { #[clap(index = 2)] path: String, - #[clap(short, long, default_value = "128")] - threshold: u8, - #[clap(short, long)] format: Option, } -fn parse_image_options( +fn parse_options( args: &str, -) -> Result> { +) -> Result> { let mut args = match shlex::split(args) { Some(args) => args, None => return Err("Failed to parse arguments".into()), }; - args.insert(0, "image_options".into()); - - let options = ImageOptions::try_parse_from(args)?; + args.insert(0, "options".into()); - Ok(ImageLoadSettings { - name: options.name, - path: options.path.into(), - threshold: options.threshold, - format: options.format.and_then(|x| ImageFormat::from_extension(x)), - }) + ImageOptions::try_parse_from(args).map_err(|e| e.into()) } diff --git a/omni-led-applications/weather/src/weather_api.rs b/omni-led-applications/weather/src/weather_api.rs index 6b3a9b5..87705b9 100644 --- a/omni-led-applications/weather/src/weather_api.rs +++ b/omni-led-applications/weather/src/weather_api.rs @@ -17,7 +17,9 @@ */ use chrono::Timelike; -use omni_led_api::types::Image; +use image::codecs::png::PngEncoder; +use image::{ExtendedColorType, ImageEncoder}; +use omni_led_api::types::{ImageData, ImageFormat}; use serde::de; use std::collections::HashMap; use ureq::Agent; @@ -274,7 +276,7 @@ fn get_image_key(weather: Weather, is_day: bool) -> &'static str { } } -pub fn load_images() -> Vec<(&'static str, Image)> { +pub fn load_images() -> Vec<(&'static str, ImageData)> { const IMAGES: &[(&str, &[u8])] = &[ ("DAY_CLEAR", include_bytes!("../assets/day_clear.png")), ("NIGHT_CLEAR", include_bytes!("../assets/night_clear.png")), @@ -293,12 +295,20 @@ pub fn load_images() -> Vec<(&'static str, Image)> { let mut image = image::load_from_memory_with_format(bytes, image::ImageFormat::Png).unwrap(); image.invert(); - let grayscale = image.into_luma8(); - let image = Image { - width: grayscale.width() as i64, - height: grayscale.height() as i64, - data: grayscale.into_raw(), + let mut buffer = Vec::new(); + PngEncoder::new(&mut buffer) + .write_image( + image.as_bytes(), + image.width(), + image.height(), + ExtendedColorType::Rgba8, + ) + .unwrap(); + + let image = ImageData { + data: buffer, + format: ImageFormat::Png as i32, }; (*name, image) }) diff --git a/omni-led/src/common/common.rs b/omni-led/src/common/common.rs index 4f0d536..8f3e5e8 100644 --- a/omni-led/src/common/common.rs +++ b/omni-led/src/common/common.rs @@ -19,8 +19,9 @@ use mlua::{chunk, ErrorContext, Lua, ObjectLike, Table, Value}; use omni_led_api::types::field::Field as FieldEntry; use omni_led_api::types::Field; +use std::hash::{DefaultHasher, Hash, Hasher}; -use crate::script_handler::script_data_types::{OledImage, Size}; +use crate::script_handler::script_data_types::ImageData; #[macro_export] macro_rules! create_table { @@ -143,16 +144,21 @@ pub fn proto_to_lua_value(lua: &Lua, field: Field) -> mlua::Result { } Ok(Value::Table(table)) } - Some(FieldEntry::FImage(image)) => { - let oled_image = OledImage { - size: Size { - width: image.width as usize, - height: image.height as usize, - }, + Some(FieldEntry::FImageData(image)) => { + let hash = hash(&image.data); + let image_data = ImageData { + format: image.format().into(), bytes: image.data, + hash: Some(hash), }; - let user_data = lua.create_any_userdata(oled_image)?; + let user_data = lua.create_any_userdata(image_data)?; Ok(Value::UserData(user_data)) } } } + +pub fn hash(t: &T) -> u64 { + let mut s = DefaultHasher::new(); + t.hash(&mut s); + s.finish() +} diff --git a/omni-led/src/renderer/animation.rs b/omni-led/src/renderer/animation.rs new file mode 100644 index 0000000..194fa19 --- /dev/null +++ b/omni-led/src/renderer/animation.rs @@ -0,0 +1,172 @@ +/* + * OmniLED is a software for displaying data on various OLED devices. + * Copyright (C) 2025 Michał Bałabanow + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +use crate::script_handler::script_data_types::Repeat; + +#[derive(Clone, Debug)] +pub struct Animation { + edge_step_time: usize, + step_time: usize, + steps: usize, + total_time: usize, + repeat: Repeat, + current_tick: usize, + can_wrap: bool, +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum State { + InProgress, + Finished, + CanFinish, +} + +impl Animation { + pub fn new(edge_step_time: usize, step_time: usize, steps: usize, repeat: Repeat) -> Self { + let total_time = match steps { + 1 => 0, + _ => edge_step_time * 2 + (steps - 2) * step_time, + }; + + Self { + edge_step_time, + step_time, + steps, + total_time, + repeat, + current_tick: 1, + can_wrap: false, + } + } + + pub fn step(&mut self) -> usize { + let (step, can_wrap) = if self.current_tick >= self.total_time { + (self.steps - 1, true) + } else if self.current_tick > self.total_time - self.edge_step_time { + (self.steps - 1, false) + } else if self.current_tick <= self.edge_step_time { + (0, false) + } else { + ( + (self.current_tick - self.edge_step_time - 1) / self.step_time + 1, + false, + ) + }; + + self.current_tick += 1; + self.can_wrap = can_wrap; + + step + } + + pub fn state(&self) -> State { + match (self.repeat, self.can_wrap) { + (Repeat::Once, false) => State::InProgress, + (Repeat::Once, true) => State::Finished, + (Repeat::ForDuration, _) => State::CanFinish, + } + } + + pub fn can_wrap(&self) -> bool { + self.can_wrap + } + + pub fn reset(&mut self) { + self.current_tick = 1; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + macro_rules! step_and_assert_eq { + ($anim:ident, $step:expr, $can_wrap:expr, $state:expr) => { + assert_eq!($anim.step(), $step); + assert_eq!($anim.can_wrap(), $can_wrap); + assert_eq!($anim.state(), $state); + }; + } + + fn run_test(edge_time: usize, step_time: usize, steps: usize) { + let mut animation = Animation::new(edge_time, step_time, steps, Repeat::Once); + + let total_time = if steps == 1 { + 0 + } else { + 2 * edge_time + step_time * (steps - 2) + }; + + assert_eq!(animation.total_time, total_time); + + for _ in 0..edge_time { + step_and_assert_eq!(animation, 0, false, State::InProgress); + } + + for step in 0..steps - 2 { + for _ in 0..step_time { + step_and_assert_eq!(animation, step + 1, false, State::InProgress); + } + } + + for _ in 0..edge_time - 1 { + step_and_assert_eq!(animation, steps - 1, false, State::InProgress); + } + step_and_assert_eq!(animation, steps - 1, true, State::Finished); + } + + #[test] + fn edge_step_time_and_step_time_equal() { + const EDGE_TIME: usize = 6; + const STEP_TIME: usize = 6; + const STEPS: usize = 20; + + run_test(EDGE_TIME, STEP_TIME, STEPS); + } + + #[test] + fn edge_step_time_greater_than_step_time() { + const EDGE_TIME: usize = 8; + const STEP_TIME: usize = 2; + const STEPS: usize = 20; + + run_test(EDGE_TIME, STEP_TIME, STEPS); + } + + #[test] + fn step_time_greater_than_edge_step_time() { + const EDGE_TIME: usize = 2; + const STEP_TIME: usize = 8; + const STEPS: usize = 20; + + run_test(EDGE_TIME, STEP_TIME, STEPS); + } + + #[test] + fn single_step() { + const EDGE_TIME: usize = 7; + const STEP_TIME: usize = 5; + const STEPS: usize = 1; + + let mut animation = Animation::new(EDGE_TIME, STEP_TIME, STEPS, Repeat::Once); + + assert_eq!(animation.total_time, 0); + + step_and_assert_eq!(animation, 0, true, State::Finished); + } +} diff --git a/omni-led/src/renderer/animation_group.rs b/omni-led/src/renderer/animation_group.rs new file mode 100644 index 0000000..82927bf --- /dev/null +++ b/omni-led/src/renderer/animation_group.rs @@ -0,0 +1,148 @@ +/* + * OmniLED is a software for displaying data on various OLED devices. + * Copyright (C) 2025 Michał Bałabanow + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +use crate::renderer::animation::{Animation, State}; + +#[derive(Clone)] +pub struct AnimationGroup { + items: Vec, + new_data: bool, + keep_in_sync: bool, +} + +#[derive(Clone)] +struct Item { + hash: u64, + animation: Animation, + accessed: bool, +} + +impl AnimationGroup { + pub fn new(keep_in_sync: bool) -> Self { + Self { + items: Vec::new(), + new_data: false, + keep_in_sync, + } + } + + pub fn entry(&mut self, hash: u64) -> Entry { + let mut index = None; + for (i, item) in self.items.iter_mut().enumerate() { + if item.hash == hash { + index = Some(i); + break; + } + } + + match index { + Some(index) => Entry::Occupied(OccupiedEntry { + _hash: hash, + item: &mut self.items[index], + }), + None => Entry::Vacant(VacantEntry { hash, group: self }), + } + } + + pub fn pre_sync(&mut self) { + self.items.retain_mut(|item| { + if item.accessed { + if self.new_data && self.keep_in_sync { + item.animation.reset(); + item.accessed = false; + } + true + } else { + false + } + }); + self.new_data = false; + } + + pub fn sync(&mut self) { + if self.keep_in_sync { + let all_can_wrap = self.items.iter().all(|item| item.animation.can_wrap()); + if all_can_wrap { + for item in &mut self.items { + item.animation.reset(); + } + } + } else { + for item in &mut self.items { + if item.animation.can_wrap() { + item.animation.reset(); + } + } + } + } + + pub fn states(&self) -> Vec { + self.items + .iter() + .map(|item| item.animation.state()) + .collect() + } +} + +pub enum Entry<'a> { + Occupied(OccupiedEntry<'a>), + Vacant(VacantEntry<'a>), +} + +impl<'a> Entry<'a> { + pub fn or_insert_with Animation>(self, f: F) -> &'a mut Animation { + match self { + Entry::Occupied(entry) => { + entry.item.accessed = true; + &mut entry.item.animation + } + Entry::Vacant(entry) => { + entry.group.new_data = true; + entry.group.items.push(Item { + hash: entry.hash, + animation: f(), + accessed: true, + }); + let index = entry.group.items.len() - 1; + &mut entry.group.items[index].animation + } + } + } + + pub fn unwrap(self) -> &'a mut Animation { + match self { + Entry::Occupied(entry) => { + entry.item.accessed = true; + &mut entry.item.animation + } + Entry::Vacant(entry) => { + panic!("Entry with hash {} doesn't exist", entry.hash); + } + } + } +} + +pub struct OccupiedEntry<'a> { + _hash: u64, + item: &'a mut Item, +} + +pub struct VacantEntry<'a> { + hash: u64, + group: &'a mut AnimationGroup, +} diff --git a/omni-led/src/renderer/bit.rs b/omni-led/src/renderer/bit.rs index 62a8a9c..61ea782 100644 --- a/omni-led/src/renderer/bit.rs +++ b/omni-led/src/renderer/bit.rs @@ -17,28 +17,56 @@ */ pub struct Bit<'a> { - byte: &'a mut u8, + byte: &'a u8, offset: usize, } -const MASK: [u8; 8] = [ - 0b00000001, 0b00000010, 0b00000100, 0b00001000, 0b00010000, 0b00100000, 0b01000000, 0b10000000, -]; - impl<'a> Bit<'a> { + pub fn new(byte: &'a u8, offset: usize) -> Self { + Self { byte, offset } + } + + pub fn get(&self) -> bool { + get(*self.byte, self.offset) + } +} + +pub struct BitMut<'a> { + byte: &'a mut u8, + offset: usize, +} + +impl<'a> BitMut<'a> { pub fn new(byte: &'a mut u8, offset: usize) -> Self { Self { byte, offset } } + #[allow(unused)] pub fn get(&self) -> bool { - (*self.byte & MASK[self.offset]) != 0 + get(*self.byte, self.offset) } pub fn set(&mut self) { - *self.byte |= MASK[self.offset] + set(self.byte, self.offset) } pub fn reset(&mut self) { - *self.byte &= !MASK[self.offset] + reset(self.byte, self.offset) } } + +const MASK: [u8; 8] = [ + 0b00000001, 0b00000010, 0b00000100, 0b00001000, 0b00010000, 0b00100000, 0b01000000, 0b10000000, +]; + +fn get(byte: u8, offset: usize) -> bool { + (byte & MASK[offset]) != 0 +} + +fn set(byte: &mut u8, offset: usize) { + *byte |= MASK[offset] +} + +fn reset(byte: &mut u8, offset: usize) { + *byte &= !MASK[offset] +} diff --git a/omni-led/src/renderer/buffer.rs b/omni-led/src/renderer/buffer.rs index 37fba36..fe65fe4 100644 --- a/omni-led/src/renderer/buffer.rs +++ b/omni-led/src/renderer/buffer.rs @@ -18,7 +18,7 @@ use mlua::{UserData, UserDataMethods}; -use crate::renderer::bit::Bit; +use crate::renderer::bit::{Bit, BitMut}; use crate::script_handler::script_data_types::Modifiers; use crate::script_handler::script_data_types::{Rectangle, Size}; @@ -79,12 +79,12 @@ impl Buffer { modifiers: &Modifiers, ) -> Option<(usize, usize)> { let (x, y) = match modifiers.flip_vertical { - true => (x, area.size.height as isize - y), + true => (x, area.size.height as isize - y - 1), false => (x, y), }; let (x, y) = match modifiers.flip_horizontal { - true => (area.size.width as isize - x, y), + true => (area.size.width as isize - x - 1, y), false => (x, y), }; @@ -120,7 +120,7 @@ pub trait BufferTrait { fn bytes(&self) -> &Vec; fn row_stride(&self) -> usize; #[allow(unused)] - fn get(&mut self, x: usize, y: usize) -> Option; + fn get(&self, x: usize, y: usize) -> Option; fn set(&mut self, x: usize, y: usize); fn reset(&mut self, x: usize, y: usize); } @@ -140,13 +140,23 @@ impl ByteBuffer { } } - fn bit_at(&mut self, x: usize, y: usize) -> Option<&mut u8> { + fn byte_position(&self, x: usize, y: usize) -> Option { if x >= self.width || y >= self.height { return None; } let index = y * self.width + x; - Some(&mut self.data[index]) + Some(index) + } + + fn get_byte(&self, x: usize, y: usize) -> Option<&u8> { + self.byte_position(x, y) + .and_then(|index| Some(&self.data[index])) + } + + fn get_byte_mut(&mut self, x: usize, y: usize) -> Option<&mut u8> { + self.byte_position(x, y) + .and_then(|index| Some(&mut self.data[index])) } } @@ -167,18 +177,18 @@ impl BufferTrait for ByteBuffer { self.width } - fn get(&mut self, x: usize, y: usize) -> Option { - self.bit_at(x, y).and_then(|value| Some(*value > 0)) + fn get(&self, x: usize, y: usize) -> Option { + self.get_byte(x, y).and_then(|value| Some(*value > 0)) } fn set(&mut self, x: usize, y: usize) { - if let Some(value) = self.bit_at(x, y) { + if let Some(value) = self.get_byte_mut(x, y) { *value = 0xFF; } } fn reset(&mut self, x: usize, y: usize) { - if let Some(value) = self.bit_at(x, y) { + if let Some(value) = self.get_byte_mut(x, y) { *value = 0x00; } } @@ -204,13 +214,25 @@ impl BitBuffer { } } - fn bit_at(&mut self, x: usize, y: usize) -> Option { + fn bit_position(&self, x: usize, y: usize) -> Option<(usize, usize)> { if x >= self.width || y >= self.height { return None; } let index = (y * self.padded_width + x) / 8; - Some(Bit::new(&mut self.data[index], 7 - x % 8)) + let offset = 7 - x % 8; + + Some((index, offset)) + } + + fn get_bit(&self, x: usize, y: usize) -> Option { + self.bit_position(x, y) + .and_then(|(index, offset)| Some(Bit::new(&self.data[index], offset))) + } + + fn get_bit_mut(&mut self, x: usize, y: usize) -> Option { + self.bit_position(x, y) + .and_then(|(index, offset)| Some(BitMut::new(&mut self.data[index], offset))) } } @@ -230,18 +252,18 @@ impl BufferTrait for BitBuffer { self.padded_width / 8 } - fn get(&mut self, x: usize, y: usize) -> Option { - self.bit_at(x, y).and_then(|bit| Some(bit.get())) + fn get(&self, x: usize, y: usize) -> Option { + self.get_bit(x, y).and_then(|bit| Some(bit.get())) } fn set(&mut self, x: usize, y: usize) { - if let Some(mut bit) = self.bit_at(x, y) { + if let Some(mut bit) = self.get_bit_mut(x, y) { bit.set(); } } fn reset(&mut self, x: usize, y: usize) { - if let Some(mut bit) = self.bit_at(x, y) { + if let Some(mut bit) = self.get_bit_mut(x, y) { bit.reset(); } } diff --git a/omni-led/src/renderer/font_manager.rs b/omni-led/src/renderer/font_manager.rs index 5eabcbd..e44e634 100644 --- a/omni-led/src/renderer/font_manager.rs +++ b/omni-led/src/renderer/font_manager.rs @@ -28,7 +28,7 @@ use std::sync::Arc; use crate::renderer::bit::Bit; use crate::renderer::font_selector::FontSelector; -use crate::script_handler::script_data_types::Offset; +use crate::script_handler::script_data_types::FontSize; pub struct FontManager { _library: freetype::Library, @@ -74,24 +74,27 @@ impl FontManager { } } - pub fn get_font_size(&self, max_height: usize, offset_type: &Offset) -> usize { - let scale = match offset_type { - Offset::Value(_) | Offset::Auto => self.metrics.full_scale, - Offset::AutoUpper => self.metrics.ascender_only_scale, - }; - - let size = max_height as f64 * scale; - size.round() as usize + pub fn get_font_size(&self, font_setting: FontSize, max_height: usize) -> usize { + match font_setting { + FontSize::Value(value) => value, + FontSize::Auto => { + let size = max_height as f64 * self.metrics.full_scale; + size.round() as usize + } + FontSize::AutoUpper => { + let size = max_height as f64 * self.metrics.ascender_only_scale; + size.round() as usize + } + } } - pub fn get_offset(&self, font_size: usize, offset_type: &Offset) -> isize { - match offset_type { - Offset::Value(offset) => *offset, - Offset::Auto => { - let offset = font_size as f64 * self.metrics.offset_scale; + pub fn get_offset(&self, font_setting: FontSize, actual_font_size: usize) -> isize { + match font_setting { + FontSize::Value(_) | FontSize::Auto => { + let offset = actual_font_size as f64 * self.metrics.offset_scale; offset.ceil() as isize } - Offset::AutoUpper => 1, + FontSize::AutoUpper => 1, } } diff --git a/omni-led/src/renderer/images.rs b/omni-led/src/renderer/images.rs new file mode 100644 index 0000000..b43aa11 --- /dev/null +++ b/omni-led/src/renderer/images.rs @@ -0,0 +1,96 @@ +/* + * OmniLED is a software for displaying data on various OLED devices. + * Copyright (C) 2025 Michał Bałabanow + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +use image::codecs::{gif::GifDecoder, png::PngDecoder, webp::WebPDecoder}; +use image::imageops::FilterType; +use image::{AnimationDecoder, DynamicImage, ImageFormat}; +use std::collections::HashMap; +use std::io::{BufReader, Cursor}; + +use crate::renderer::buffer::{BitBuffer, BufferTrait}; +use crate::script_handler::script_data_types::{ImageData, Size}; + +pub type CacheKey = (u64, Size, u8); +pub type ImageCache = HashMap>; + +pub fn render_image<'a>( + cache: &'a mut ImageCache, + image: &ImageData, + size: Size, + threshold: u8, + animated: bool, +) -> &'a Vec { + cache + .entry((image.hash.unwrap(), size, threshold)) + .or_insert_with(|| { + if animated { + render_animated_image(image, size, threshold) + } else { + render_static_image(image, size, threshold) + } + }) +} + +fn render_static_image(image: &ImageData, size: Size, threshold: u8) -> Vec { + let image = image::load_from_memory_with_format(&image.bytes, image.format).unwrap(); + + vec![render_into_buffer(image, size, threshold)] +} + +fn render_animated_image(image: &ImageData, size: Size, threshold: u8) -> Vec { + let reader = BufReader::new(Cursor::new(&image.bytes)); + + let frames = match image.format { + ImageFormat::Png => { + let decoder = PngDecoder::new(reader).unwrap().apng().unwrap(); + decoder.into_frames() + } + ImageFormat::Gif => { + let decoder = GifDecoder::new(reader).unwrap(); + decoder.into_frames() + } + ImageFormat::WebP => { + let decoder = WebPDecoder::new(reader).unwrap(); + decoder.into_frames() + } + _ => panic!("Unsupported image format {:?}", image.format), + }; + + frames + .collect_frames() + .unwrap() + .into_iter() + .map(|frame| { + let image = DynamicImage::from(frame.into_buffer()); + render_into_buffer(image, size, threshold) + }) + .collect() +} + +fn render_into_buffer(image: DynamicImage, size: Size, threshold: u8) -> BitBuffer { + let image = image.resize_exact(size.width as u32, size.height as u32, FilterType::Nearest); + let image = image.into_luma8(); + + let mut buffer = BitBuffer::new(size); + for (x, y, pixel) in image.enumerate_pixels() { + if pixel[0] >= threshold { + buffer.set(x as usize, y as usize); + } + } + buffer +} diff --git a/omni-led/src/renderer/mod.rs b/omni-led/src/renderer/mod.rs index d9e436e..b93e0e1 100644 --- a/omni-led/src/renderer/mod.rs +++ b/omni-led/src/renderer/mod.rs @@ -16,9 +16,12 @@ * along with this program. If not, see . */ +pub mod animation; +pub mod animation_group; pub mod buffer; pub mod font_selector; pub mod renderer; mod bit; mod font_manager; +mod images; diff --git a/omni-led/src/renderer/renderer.rs b/omni-led/src/renderer/renderer.rs index 3c5eafb..d3602a4 100644 --- a/omni-led/src/renderer/renderer.rs +++ b/omni-led/src/renderer/renderer.rs @@ -19,23 +19,40 @@ use mlua::Lua; use num_traits::clamp; use std::cmp::max; -use std::collections::hash_map::Entry; use std::collections::HashMap; -use std::vec::IntoIter; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::str::Chars; use crate::common::user_data::UserDataRef; -use crate::renderer::buffer::{BitBuffer, Buffer, ByteBuffer}; +use crate::renderer::animation::{Animation, State}; +use crate::renderer::animation_group::AnimationGroup; +use crate::renderer::buffer::{BitBuffer, Buffer, BufferTrait, ByteBuffer}; use crate::renderer::font_manager::FontManager; +use crate::renderer::images; +use crate::renderer::images::ImageCache; use crate::script_handler::script_data_types::{ Bar, Image, MemoryRepresentation, Modifiers, Point, Text, Widget, }; use crate::script_handler::script_data_types::{Rectangle, Size}; use crate::settings::settings::Settings; +macro_rules! get_animation_settings { + ($default:expr, $widget:expr) => { + AnimationSettings { + ticks_at_edge: $widget + .animation_ticks_delay + .unwrap_or($default.ticks_at_edge), + ticks_per_move: $widget + .animation_ticks_rate + .unwrap_or($default.ticks_per_move), + } + }; +} + pub struct Renderer { font_manager: FontManager, - scrolling_text_data: ScrollingTextData, - scrolling_text_settings: ScrollingTextSettings, + image_cache: ImageCache, + animation_settings: AnimationSettings, } impl Renderer { @@ -45,34 +62,40 @@ impl Renderer { Self { font_manager: FontManager::new(font_selector), - scrolling_text_data: ScrollingTextData::new(), - scrolling_text_settings: ScrollingTextSettings::new(lua), + image_cache: ImageCache::new(), + animation_settings: AnimationSettings::new(lua), } } pub fn render( &mut self, - ctx: ContextKey, + animation_groups: &mut HashMap, size: Size, - widgets: Vec, + mut widgets: Vec, memory_representation: MemoryRepresentation, - ) -> (bool, Buffer) { + ) -> (State, Buffer) { let mut buffer = match memory_representation { MemoryRepresentation::BitPerPixel => Buffer::new(BitBuffer::new(size)), MemoryRepresentation::BytePerPixel => Buffer::new(ByteBuffer::new(size)), }; - let (end_auto_repeat, text_offsets) = self.precalculate_text(ctx, &widgets); - let mut text_offsets = text_offsets.into_iter(); + + self.calculate_animations(animation_groups, &mut widgets); for operation in widgets { match operation { Widget::Bar(bar) => Self::render_bar(&mut buffer, bar), - Widget::Image(image) => Self::render_image(&mut buffer, image), - Widget::Text(text) => self.render_text(&mut buffer, text, &mut text_offsets), + Widget::Image(image) => self.render_image(&mut buffer, image, animation_groups), + Widget::Text(text) => self.render_text(&mut buffer, text, animation_groups), } } - (end_auto_repeat, buffer) + animation_groups + .iter_mut() + .for_each(|(_, group)| group.sync()); + + let state = Self::animation_state(animation_groups); + + (state, buffer) } fn clear_background(buffer: &mut Buffer, position: Point, size: Size, modifiers: &Modifiers) { @@ -114,77 +137,107 @@ impl Renderer { } } - fn render_image(buffer: &mut Buffer, widget: Image) { + fn render_image( + &mut self, + buffer: &mut Buffer, + widget: Image, + animation_groups: &mut HashMap, + ) { if widget.size.width == 0 || widget.size.height == 0 { return; } + let image = images::render_image( + &mut self.image_cache, + &widget.image, + widget.size, + widget.threshold, + widget.animated, + ); + + let frame = if widget.animated { + let hash = widget.image.hash.unwrap(); + let group = Self::get_animation_group(animation_groups, widget.animation_group); + let animation = group.entry(hash).unwrap(); + let step = animation.step(); + &image[step] + } else { + &image[0] + }; + + Self::render_image_impl(buffer, &widget, frame); + } + + fn render_image_impl(buffer: &mut Buffer, widget: &Image, rendered: &BitBuffer) { if widget.modifiers.clear_background { Self::clear_background(buffer, widget.position, widget.size, &widget.modifiers); } - let x_factor = widget.image.size.width as f64 / widget.size.width as f64; - let y_factor = widget.image.size.height as f64 / widget.size.height as f64; - let rect = Rectangle { position: widget.position, size: widget.size, }; - for y in 0..widget.size.height as isize { - for x in 0..widget.size.width as isize { - // Use nearest neighbour interpolation for now as it's the quickest to implement - // TODO allow specifying scaling algorithm as modifier - // TODO potentially cache scaled images - - let image_x = (x as f64 * x_factor).round() as usize; - let image_x = image_x.clamp(0, widget.image.size.width - 1); - let image_y = (y as f64 * y_factor).round() as usize; - let image_y = image_y.clamp(0, widget.image.size.height - 1); - - let index = image_y * widget.image.size.width + image_x; - if widget.image.bytes[index] != 0 { - buffer.set(x, y, &rect, &widget.modifiers); + for y in 0..rendered.height() { + for x in 0..rendered.width() { + if rendered.get(x, y).unwrap() { + buffer.set(x as isize, y as isize, &rect, &widget.modifiers); } } } } - fn render_text(&mut self, buffer: &mut Buffer, widget: Text, offsets: &mut IntoIter) { + fn render_text( + &mut self, + buffer: &mut Buffer, + widget: Text, + animation_groups: &mut HashMap, + ) { + if widget.size.width == 0 || widget.size.height == 0 { + return; + } + if widget.modifiers.clear_background { Self::clear_background(buffer, widget.position, widget.size, &widget.modifiers); } - let mut cursor_x = 0; - let cursor_y = widget.size.height as isize; - - let offset = offsets.next().expect("Each 'Text' shall have its offset"); let mut characters = widget.text.chars(); - for _ in 0..offset { - _ = characters.next(); + + if widget.scrolling { + let hash = widget.hash.unwrap(); + let group = Self::get_animation_group(animation_groups, widget.animation_group); + let animation = group.entry(hash).unwrap(); + let step = animation.step(); + + for _ in 0..step { + _ = characters.next(); + } } + Self::render_text_impl(buffer, &mut self.font_manager, &widget, characters); + } + + fn render_text_impl( + buffer: &mut Buffer, + font_manager: &mut FontManager, + widget: &Text, + characters: Chars, + ) { let rect = Rectangle { position: widget.position, size: widget.size, }; - let (font_size, text_offset) = match (widget.font_size, widget.text_offset) { - (Some(font_size), offset_type) => { - let offset = self.font_manager.get_offset(font_size, &offset_type); - (font_size, offset) - } - (None, offset_type) => { - let font_size = self - .font_manager - .get_font_size(rect.size.height, &offset_type); - let offset = self.font_manager.get_offset(font_size, &offset_type); - (font_size, offset) - } - }; + let font_size = font_manager.get_font_size(widget.font_size, widget.size.height); + let text_offset = widget + .text_offset + .unwrap_or_else(|| font_manager.get_offset(widget.font_size, font_size)); + + let mut cursor_x = 0; + let cursor_y = widget.size.height as isize; for character in characters { - let character = self.font_manager.get_character(character, font_size); + let character = font_manager.get_character(character, font_size); let bitmap = &character.bitmap; for bitmap_y in 0..bitmap.rows as isize { @@ -213,181 +266,137 @@ impl Renderer { } } - fn precalculate_text(&mut self, ctx: ContextKey, widgets: &Vec) -> (bool, Vec) { - let mut ctx = self.scrolling_text_data.get_context(ctx); + fn calculate_animations( + &mut self, + animation_groups: &mut HashMap, + widgets: &mut Vec, + ) { + for widget in widgets { + match widget { + Widget::Bar(_) => continue, + Widget::Image(image) => { + if !image.animated { + continue; + } - let offsets: Vec = widgets - .iter() - .filter_map(|widget| match widget { - Widget::Text(text) => Some(Self::precalculate_single( - &mut ctx, - &mut self.font_manager, - &self.scrolling_text_settings, - text, - )), - _ => None, - }) - .collect(); - - match ctx.has_new_data() { - true => (false, vec![0; offsets.len()]), - false => (ctx.can_wrap(), offsets), + Self::calculate_animation_hash(&image.image.bytes, &mut image.image.hash); + + let group = Self::get_animation_group(animation_groups, image.animation_group); + group.entry(image.image.hash.unwrap()).or_insert_with(|| { + let settings = get_animation_settings!(self.animation_settings, image); + let rendered = images::render_image( + &mut self.image_cache, + &image.image, + image.size, + image.threshold, + image.animated, + ); + Animation::new( + settings.ticks_at_edge, + settings.ticks_per_move, + rendered.len(), + image.repeats, + ) + }); + } + Widget::Text(text) => { + if !text.scrolling { + continue; + } + + Self::calculate_animation_hash(&text.text, &mut text.hash); + + let group = Self::get_animation_group(animation_groups, text.animation_group); + group.entry(text.hash.unwrap()).or_insert_with(|| { + let settings = get_animation_settings!(self.animation_settings, text); + let steps = Self::pre_render_text(&mut self.font_manager, text); + Animation::new( + settings.ticks_at_edge, + settings.ticks_per_move, + steps, + text.repeats, + ) + }); + } + }; } - } - fn precalculate_single( - ctx: &mut Context, - font_manager: &mut FontManager, - settings: &ScrollingTextSettings, - text: &Text, - ) -> usize { - if !text.scrolling { - return 0; + for (_, group) in animation_groups { + group.pre_sync(); } + } - let font_size = match (text.font_size, text.text_offset) { - (Some(font_size), _) => font_size, - (None, offset_type) => font_manager.get_font_size(text.size.height, &offset_type), + fn get_animation_group( + animation_groups: &mut HashMap, + number: Option, + ) -> &mut AnimationGroup { + let (number, synced) = match number { + Some(0) | None => (0, false), + Some(number) => (number, true), }; + + animation_groups + .entry(number) + .or_insert(AnimationGroup::new(synced)) + } + + fn calculate_animation_hash(value: &H, hash: &mut Option) { + *hash = match hash { + Some(hash) => Some(*hash), + None => { + let mut s = DefaultHasher::new(); + value.hash(&mut s); + Some(s.finish()) + } + } + } + + fn animation_state(animation_groups: &HashMap) -> State { + let states = animation_groups + .iter() + .map(|(_, group)| group.states()) + .flatten() + .collect::>(); + + let all_finished = states.iter().all(|state| *state == State::Finished); + let any_in_progress = states.iter().any(|state| *state == State::InProgress); + + if all_finished { + State::Finished + } else if any_in_progress { + State::InProgress + } else { + State::CanFinish + } + } + + fn pre_render_text(font_manager: &mut FontManager, text: &Text) -> usize { + let font_size = font_manager.get_font_size(text.font_size, text.size.height); let text_width = text.size.width; let character = font_manager.get_character('a', font_size); let char_width = character.metrics.advance as usize; let max_characters = text_width / max(char_width, 1); let len = text.text.chars().count(); - let tick = ctx.get_tick(&text.text); if len <= max_characters { - ctx.set_wrap(&text.text); - return 0; - } - - let max_shifts = len - max_characters; - let max_ticks = 2 * settings.ticks_at_edge + max_shifts * settings.ticks_per_move; - if tick >= max_ticks { - ctx.set_wrap(&text.text); - } - - if tick <= settings.ticks_at_edge { - 0 - } else if tick >= settings.ticks_at_edge + max_shifts * settings.ticks_per_move { - max_shifts + 1 } else { - (tick - settings.ticks_at_edge) / settings.ticks_per_move + len - max_characters + 1 } } } -struct ScrollingTextSettings { +struct AnimationSettings { ticks_at_edge: usize, ticks_per_move: usize, } -impl ScrollingTextSettings { +impl AnimationSettings { pub fn new(lua: &Lua) -> Self { let settings = UserDataRef::::load(lua); Self { - ticks_at_edge: settings.get().text_ticks_scroll_delay, - ticks_per_move: settings.get().text_ticks_scroll_rate, + ticks_at_edge: settings.get().animation_ticks_delay, + ticks_per_move: settings.get().animation_ticks_rate, } } } - -struct TextData { - tick: usize, - can_wrap: bool, - updated: bool, -} - -struct ScrollingTextData { - contexts: HashMap>, -} - -impl ScrollingTextData { - pub fn new() -> Self { - Self { - contexts: HashMap::new(), - } - } - - pub fn get_context(&mut self, ctx: ContextKey) -> Context { - let text_data = self.contexts.entry(ctx).or_insert(HashMap::new()); - Context::new(text_data) - } -} - -struct Context<'a> { - text_data: &'a mut HashMap, - new_data: bool, -} - -impl<'a> Context<'a> { - pub fn new(text_data: &'a mut HashMap) -> Self { - Self { - text_data, - new_data: false, - } - } - - pub fn get_tick(&mut self, key: &String) -> usize { - match self.text_data.entry(key.clone()) { - Entry::Occupied(mut data) => { - let data = data.get_mut(); - if !data.updated { - data.tick += 1; - data.updated = true; - } - data.tick - } - Entry::Vacant(data) => { - self.new_data = true; - - let tick = 0; - data.insert(TextData { - tick, - can_wrap: false, - updated: true, - }); - tick - } - } - } - - pub fn set_wrap(&mut self, key: &String) { - if let Some(data) = self.text_data.get_mut(key) { - data.can_wrap = true; - }; - } - - pub fn can_wrap(&self) -> bool { - self.new_data || self.text_data.iter().all(|(_, data)| data.can_wrap) - } - - pub fn has_new_data(&self) -> bool { - self.new_data - } -} - -impl<'a> Drop for Context<'a> { - fn drop(&mut self) { - if self.new_data { - self.text_data.retain(|_, data| data.updated); - } - - if self.can_wrap() { - for (_, data) in &mut *self.text_data { - data.tick = 0; - } - } - - for (_, data) in &mut *self.text_data { - data.can_wrap = false; - data.updated = false; - } - } -} - -#[derive(Eq, Hash, PartialEq)] -pub struct ContextKey { - pub script: usize, - pub device: usize, -} diff --git a/omni-led/src/script_handler/script_data_types.rs b/omni-led/src/script_handler/script_data_types.rs index 84b0b5e..0204202 100644 --- a/omni-led/src/script_handler/script_data_types.rs +++ b/omni-led/src/script_handler/script_data_types.rs @@ -18,6 +18,7 @@ use mlua::{ErrorContext, FromLua, Lua, Table, UserData, UserDataFields, Value}; use omni_led_derive::FromLuaValue; +use std::hash::Hash; #[derive(Debug, Clone, Copy, FromLuaValue)] pub struct Point { @@ -50,12 +51,60 @@ pub struct Rectangle { impl UserData for Rectangle {} #[derive(Debug, Clone, FromLuaValue)] -pub struct OledImage { - pub size: Size, +pub struct ImageData { + #[mlua(transform(Self::parse_format))] + pub format: image::ImageFormat, pub bytes: Vec, + pub hash: Option, } -impl UserData for OledImage {} +/// This is a private enum used just to facilitate easier parsing into image::ImageFormat type +#[derive(Clone, Debug, FromLuaValue)] +enum ImageFormatEnum { + Avif, + Bmp, + Dds, + Farbfeld, + Gif, + Hdr, + Ico, + Jpeg, + OpenExr, + Pcx, + Png, + Pnm, + Qoi, + Tga, + Tiff, + WebP, +} + +impl UserData for ImageFormatEnum {} + +impl ImageData { + fn parse_format(format: ImageFormatEnum, _: &Lua) -> mlua::Result { + match format { + ImageFormatEnum::Avif => Ok(image::ImageFormat::Avif), + ImageFormatEnum::Bmp => Ok(image::ImageFormat::Bmp), + ImageFormatEnum::Dds => Ok(image::ImageFormat::Dds), + ImageFormatEnum::Farbfeld => Ok(image::ImageFormat::Farbfeld), + ImageFormatEnum::Gif => Ok(image::ImageFormat::Gif), + ImageFormatEnum::Hdr => Ok(image::ImageFormat::Hdr), + ImageFormatEnum::Ico => Ok(image::ImageFormat::Ico), + ImageFormatEnum::Jpeg => Ok(image::ImageFormat::Jpeg), + ImageFormatEnum::OpenExr => Ok(image::ImageFormat::OpenExr), + ImageFormatEnum::Pcx => Ok(image::ImageFormat::Pcx), + ImageFormatEnum::Png => Ok(image::ImageFormat::Png), + ImageFormatEnum::Pnm => Ok(image::ImageFormat::Pnm), + ImageFormatEnum::Qoi => Ok(image::ImageFormat::Qoi), + ImageFormatEnum::Tga => Ok(image::ImageFormat::Tga), + ImageFormatEnum::Tiff => Ok(image::ImageFormat::Tiff), + ImageFormatEnum::WebP => Ok(image::ImageFormat::WebP), + } + } +} + +impl UserData for ImageData {} #[derive(Clone, Debug, FromLua)] pub enum Widget { @@ -104,9 +153,24 @@ pub struct Bar { impl UserData for Bar {} +#[derive(FromLuaValue, Debug, PartialEq, Copy, Clone)] +pub enum Repeat { + Once, + ForDuration, +} + #[derive(Clone, Debug, FromLuaValue)] pub struct Image { - pub image: OledImage, + pub image: ImageData, + #[mlua(default(false))] + pub animated: bool, + #[mlua(default(128))] + pub threshold: u8, + #[mlua(default(Repeat::ForDuration))] + pub repeats: Repeat, + pub animation_group: Option, + pub animation_ticks_delay: Option, + pub animation_ticks_rate: Option, pub position: Point, pub size: Size, @@ -119,13 +183,20 @@ impl UserData for Image {} #[derive(Clone, Debug, FromLuaValue)] pub struct Text { pub text: String, - #[mlua(default(Offset::Auto))] - pub text_offset: Offset, - pub font_size: Option, + pub text_offset: Option, + #[mlua(default(FontSize::Auto))] + pub font_size: FontSize, #[mlua(default(false))] pub scrolling: bool, + #[mlua(default(Repeat::ForDuration))] + pub repeats: Repeat, + pub animation_group: Option, + pub animation_ticks_delay: Option, + pub animation_ticks_rate: Option, pub position: Point, pub size: Size, + #[mlua(default(None))] + pub hash: Option, #[mlua(default)] pub modifiers: Modifiers, @@ -134,23 +205,23 @@ pub struct Text { impl UserData for Text {} #[derive(Copy, Clone, Debug)] -pub enum Offset { - Value(isize), +pub enum FontSize { + Value(usize), Auto, AutoUpper, } -impl UserData for Offset {} +impl UserData for FontSize {} -impl FromLua for Offset { +impl FromLua for FontSize { fn from_lua(value: Value, _lua: &Lua) -> mlua::Result { match value { - Value::Integer(value) => Ok(Offset::Value(value as isize)), + Value::Integer(value) => Ok(FontSize::Value(value as usize)), Value::String(value) => { let value = value.to_string_lossy(); match value.as_str() { - "Auto" => Ok(Offset::Auto), - "AutoUpper" => Ok(Offset::AutoUpper), + "Auto" => Ok(FontSize::Auto), + "AutoUpper" => Ok(FontSize::AutoUpper), _ => Err(mlua::Error::runtime(format!( "Expected one of ['Auto', 'AutoUpper'], got '{}'", value diff --git a/omni-led/src/script_handler/script_handler.rs b/omni-led/src/script_handler/script_handler.rs index 901f214..e3c692f 100644 --- a/omni-led/src/script_handler/script_handler.rs +++ b/omni-led/src/script_handler/script_handler.rs @@ -20,6 +20,7 @@ use log::warn; use mlua::{chunk, ErrorContext, FromLua, Function, Lua, Table, UserData, UserDataMethods, Value}; use omni_led_derive::{FromLuaValue, UniqueUserData}; use std::cell::RefCell; +use std::collections::HashMap; use std::rc::Rc; use std::time::Duration; @@ -29,7 +30,9 @@ use crate::create_table_with_defaults; use crate::devices::device::Device; use crate::devices::devices::{DeviceStatus, Devices}; use crate::events::shortcuts::Shortcuts; -use crate::renderer::renderer::{ContextKey, Renderer}; +use crate::renderer::animation::State; +use crate::renderer::animation_group::AnimationGroup; +use crate::renderer::renderer::Renderer; use crate::script_handler::script_data_types::{load_script_data_types, Widget}; use crate::settings::settings::get_full_path; @@ -44,11 +47,11 @@ struct DeviceContext { device: Box, name: String, layouts: Vec, + animation_groups: Vec>, marked_for_update: Vec, time_remaining: Duration, last_priority: usize, - repeats: Option, - index: usize, + state: State, } const DEFAULT_UPDATE_TIME: Duration = Duration::from_millis(1000); @@ -112,7 +115,7 @@ impl ScriptHandler { ctx.marked_for_update = vec![false; ctx.marked_for_update.len()]; ctx.time_remaining = Duration::ZERO; ctx.last_priority = 0; - ctx.repeats = None; + ctx.state = State::Finished; } None => { warn!("Device {} not found", device_name); @@ -129,18 +132,17 @@ impl ScriptHandler { let mut devices = UserDataRef::::load(lua); let device = devices.get_mut().load_device(lua, device_name.clone())?; - let device_count = self.devices.len(); let layout_count = layouts.len(); let context = DeviceContext { device, name: device_name, layouts, + animation_groups: vec![HashMap::new(); layout_count], marked_for_update: vec![false; layout_count], time_remaining: Default::default(), last_priority: 0, - repeats: None, - index: device_count, + state: State::Finished, }; self.devices.push(context); @@ -168,25 +170,21 @@ impl ScriptHandler { std::mem::swap(&mut ctx.marked_for_update, &mut marked_for_update); let mut to_update = None; - let mut update_modifier = None; + let mut new_update = false; for (priority, marked_for_update) in marked_for_update.into_iter().enumerate() { - if !ctx.time_remaining.is_zero() && ctx.last_priority < priority { - if let Some(Repeat::ForDuration) = ctx.repeats { - to_update = Some(ctx.last_priority); - update_modifier = Some(Repeat::ForDuration); - } - break; - } + let can_finish = !ctx.time_remaining.is_zero() + && ctx.last_priority < priority + && ctx.state == State::CanFinish; + let in_progress = ctx.last_priority == priority && ctx.state == State::InProgress; - if ctx.last_priority == priority && ctx.repeats == Some(Repeat::Once) { + if can_finish || in_progress { to_update = Some(ctx.last_priority); - update_modifier = Some(Repeat::Once); break; } if marked_for_update && Self::test_predicate(&ctx.layouts[priority].predicate)? { to_update = Some(priority); - update_modifier = None; + new_update = true; break; } } @@ -201,11 +199,8 @@ impl ScriptHandler { env.set("SCREEN", size)?; let output: LayoutData = ctx.layouts[to_update].layout.call(())?; - let (end_auto_repeat, image) = renderer.render( - ContextKey { - script: to_update, - device: ctx.index, - }, + let (animation_state, image) = renderer.render( + &mut ctx.animation_groups[to_update], size, output.widgets, memory_representation, @@ -213,16 +208,12 @@ impl ScriptHandler { ctx.device.update(lua, image)?; - ctx.repeats = match (output.repeats, end_auto_repeat) { - (Repeat::ForDuration, _) => Some(Repeat::ForDuration), - (Repeat::Once, false) => Some(Repeat::Once), - (_, _) => None, - }; - ctx.time_remaining = match update_modifier { - Some(Repeat::ForDuration) => ctx.time_remaining, + ctx.time_remaining = match (new_update, animation_state) { + (false, State::CanFinish) => ctx.time_remaining, _ => output.duration, }; ctx.last_priority = to_update; + ctx.state = animation_state; Ok(()) } @@ -283,21 +274,12 @@ struct Layout { run_on: Vec, } -#[derive(FromLuaValue, Debug, PartialEq, Copy, Clone)] -enum Repeat { - Once, - ForDuration, -} - #[derive(FromLuaValue, Clone)] struct LayoutData { widgets: Vec, #[mlua(transform(Self::transform_duration))] duration: Duration, - - #[mlua(default(Repeat::Once))] - repeats: Repeat, } impl LayoutData { diff --git a/omni-led/src/server/server.rs b/omni-led/src/server/server.rs index 96ca2f6..2099031 100644 --- a/omni-led/src/server/server.rs +++ b/omni-led/src/server/server.rs @@ -56,9 +56,13 @@ impl PluginServer { tokio::task::spawn( Server::builder() - .add_service(omni_led_api::types::plugin_server::PluginServer::new( - Self::new(log_level_filter), - )) + .add_service( + omni_led_api::types::plugin_server::PluginServer::new(Self::new( + log_level_filter, + )) + .max_decoding_message_size(64 * 1024 * 1024) + .max_encoding_message_size(64 * 1024 * 1024), + ) .serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener)), ); diff --git a/omni-led/src/settings/settings.rs b/omni-led/src/settings/settings.rs index f0a3bc2..0c8591b 100644 --- a/omni-led/src/settings/settings.rs +++ b/omni-led/src/settings/settings.rs @@ -31,6 +31,12 @@ use crate::renderer::font_selector::FontSelector; #[derive(Debug, Clone, UniqueUserData, FromLuaValue)] pub struct Settings { + #[mlua(default(8))] + pub animation_ticks_delay: usize, + + #[mlua(default(2))] + pub animation_ticks_rate: usize, + #[mlua(default(FontSelector::Default))] pub font: FontSelector, @@ -43,12 +49,6 @@ pub struct Settings { #[mlua(default(2))] pub keyboard_ticks_repeat_rate: usize, - #[mlua(default(8))] - pub text_ticks_scroll_delay: usize, - - #[mlua(default(2))] - pub text_ticks_scroll_rate: usize, - #[mlua(default(0))] pub server_port: u16,