Add parsing of image filter strings
This commit is contained in:
parent
85214d6d35
commit
3fe1912389
7 changed files with 182 additions and 79 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -643,6 +643,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "05aaa8004b64fd573fc9d002f4e632d51ad4f026c2b5ba95fcb6c2f32c2c47d8"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
@ -8,7 +8,7 @@ color-eyre = "0.6.2"
|
|||
humantime-serde = "1.1.1"
|
||||
loupedeck_serial = { path = "../loupedeck_serial" }
|
||||
piet = "0.6.2"
|
||||
rgb = "0.8.37"
|
||||
rgb = { version = "0.8.37", features = ["serde"] }
|
||||
serde = { version = "1.0.193", features = ["derive"] }
|
||||
serde_regex = "1.1.0"
|
||||
serde_with = "3.4.0"
|
||||
|
|
|
@ -3,7 +3,7 @@ icon = "@ph/microphone-light"
|
|||
mode.audio_volume.mode = "input"
|
||||
mode.audio_volume.regex = "Microphone"
|
||||
mode.audio_volume.label.muted = "Muted"
|
||||
mode.audio_volume.icon.inactive = "@ph/microphone-sllash-light[brighten=50,huerotate=red,opacity=90]"
|
||||
mode.audio_volume.icon.inactive = "@ph/microphone-sllash-light[alpha=90|brighten=50|huerotate=red]"
|
||||
mode.audio_volume.circle_indicator.color = "#ffffff"
|
||||
mode.audio_volume.circle_indicator.width = 2
|
||||
mode.audio_volume.circle_indicator.radius = 40
|
||||
|
|
36
deckster/src/model/geometry.rs
Normal file
36
deckster/src/model/geometry.rs
Normal file
|
@ -0,0 +1,36 @@
|
|||
use std::str::FromStr;
|
||||
|
||||
use piet::kurbo::{Rect, Vec2};
|
||||
|
||||
pub fn parse_positive_int_vec2_from_str(s: &str, separator: char) -> Result<Vec2, ()> {
|
||||
let values = s.split_once(separator);
|
||||
|
||||
if let Some((x, y)) = values {
|
||||
if let Some(x) = usize::from_str(x).ok() {
|
||||
if let Some(y) = usize::from_str(y).ok() {
|
||||
return Ok(Vec2::new(x as f64, y as f64));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(())
|
||||
}
|
||||
|
||||
pub fn parse_positive_rect_from_str(s: &str) -> Result<Rect, ()> {
|
||||
let (pairs, is_corner_points_mode) = if let Some(pairs) = s.split_once('+') {
|
||||
(pairs, false)
|
||||
} else if let Some(pairs) = s.split_once('-') {
|
||||
(pairs, true)
|
||||
} else {
|
||||
return Err(());
|
||||
};
|
||||
|
||||
let first_vec = parse_positive_int_vec2_from_str(pairs.0, 'x')?;
|
||||
let second_vec = parse_positive_int_vec2_from_str(pairs.1, 'x')?;
|
||||
|
||||
Ok(if is_corner_points_mode {
|
||||
Rect::from_points(first_vec.to_point(), second_vec.to_point())
|
||||
} else {
|
||||
Rect::from_origin_size(first_vec.to_point(), second_vec.to_size())
|
||||
})
|
||||
}
|
|
@ -1,40 +1,22 @@
|
|||
use crate::model::geometry::parse_positive_rect_from_str;
|
||||
use crate::model::rgb::parse_rgb8_from_hex_str;
|
||||
use piet::kurbo::Rect;
|
||||
use rgb::RGB8;
|
||||
use serde_with::DeserializeFromStr;
|
||||
use std::fmt::{Display, Formatter, Write};
|
||||
use std::str::FromStr;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ImageTransform {
|
||||
Invert,
|
||||
Brighten(f32),
|
||||
Crop(Rect),
|
||||
HorizontalFlip,
|
||||
VerticalFlip,
|
||||
Rotate { quarter_clockwise_turns: u8 },
|
||||
}
|
||||
|
||||
impl Display for ImageTransform {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ImageTransform::Invert => f.write_str("invert"),
|
||||
ImageTransform::Brighten(value) => f.write_fmt(format_args!("brighten={}", value)),
|
||||
ImageTransform::Crop(_) => f.write_str("crop=<...>"),
|
||||
ImageTransform::HorizontalFlip => f.write_str("horizontal_flip"),
|
||||
ImageTransform::VerticalFlip => f.write_str("vertical_flip"),
|
||||
ImageTransform::Rotate { quarter_clockwise_turns } => f.write_fmt(format_args!("rotate={}", quarter_clockwise_turns)),
|
||||
}?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeserializeFromStr)]
|
||||
pub struct ImageFilter {
|
||||
crop_original: Option<Rect>, // applied before scale and rotate
|
||||
scale: f32,
|
||||
clockwise_quarter_rotations: u8,
|
||||
crop: Option<Rect>, // applied after scale and rotate
|
||||
color: Option<RGB8>,
|
||||
alpha: f32,
|
||||
blur: f32,
|
||||
transforms: Vec<ImageTransform>,
|
||||
grayscale: bool,
|
||||
invert: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
|
@ -43,17 +25,17 @@ pub enum ImageFilterFromStringError {
|
|||
UnknownFilter { name: String },
|
||||
|
||||
#[error("Filter {name} can only be used once.")]
|
||||
SingletonSpecifiedMoreThanOnce { name: String },
|
||||
FilterUsedMoreThanOnce { name: String },
|
||||
|
||||
#[error("All singleton filters must be placed before any transforms, but {singleton} was placed after {transform}.")]
|
||||
SingletonSpecifiedAfterTransform { singleton: String, transform: ImageTransform },
|
||||
|
||||
#[error("{quarter_clockwise_turns} 90° turns can be simplified.")]
|
||||
PointlessRotation { quarter_clockwise_turns: u8 },
|
||||
#[error("Only the following values are for rotate: -90, 90, 180, 270")]
|
||||
RotationNotAllowed,
|
||||
|
||||
#[error("Filter {filter_name} requires a value ({filter_name}=<value>).")]
|
||||
FilterValueMissing { filter_name: String },
|
||||
|
||||
#[error("Filter {filter_name} does not require or allow a value.")]
|
||||
FilterValueIgnored { filter_name: String },
|
||||
|
||||
#[error("The following value supplied to {filter_name} could not be parsed: {raw_value}")]
|
||||
FilterValueNotParsable { filter_name: String, raw_value: String },
|
||||
|
||||
|
@ -64,14 +46,51 @@ pub enum ImageFilterFromStringError {
|
|||
impl FromStr for ImageFilter {
|
||||
type Err = ImageFilterFromStringError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
fn from_str<'a>(s: &str) -> Result<Self, Self::Err> {
|
||||
fn parse_rect_filter_value(filter_name: &str, raw_value: String) -> Result<Rect, ImageFilterFromStringError> {
|
||||
parse_positive_rect_from_str(&raw_value).map_err(|_| ImageFilterFromStringError::FilterValueNotParsable {
|
||||
filter_name: filter_name.to_string(),
|
||||
raw_value,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_f32_filter_value(
|
||||
filter_name: &str,
|
||||
raw_value: String,
|
||||
range_string: &str,
|
||||
check_range: Box<dyn Fn(&f32) -> bool>,
|
||||
) -> Result<f32, ImageFilterFromStringError> {
|
||||
let value = f32::from_str(&raw_value).map_err(|_| ImageFilterFromStringError::FilterValueNotParsable {
|
||||
filter_name: filter_name.to_string(),
|
||||
raw_value,
|
||||
})?;
|
||||
|
||||
if !check_range(&value) {
|
||||
return Err(ImageFilterFromStringError::FilterValueNotInRange {
|
||||
filter_name: filter_name.to_string(),
|
||||
range: range_string.to_string(),
|
||||
value: value.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
let filters: Vec<&str> = s.split('|').map(|f| f.trim()).collect();
|
||||
|
||||
let scale: Option<f32> = None;
|
||||
let alpha: Option<f32> = None;
|
||||
let blur: Option<f32> = None;
|
||||
let mut result = ImageFilter {
|
||||
crop_original: None,
|
||||
scale: 1.0,
|
||||
clockwise_quarter_rotations: 0,
|
||||
crop: None,
|
||||
color: None,
|
||||
alpha: 1.0,
|
||||
blur: 0.0,
|
||||
grayscale: false,
|
||||
invert: false,
|
||||
};
|
||||
|
||||
let mut transforms: Vec<ImageTransform> = Vec::new();
|
||||
let mut previous_filter_names: Vec<String> = Vec::new();
|
||||
|
||||
for filter in filters {
|
||||
let split_filter = filter.split_once('=');
|
||||
|
@ -81,6 +100,23 @@ impl FromStr for ImageFilter {
|
|||
(filter.to_owned(), None)
|
||||
};
|
||||
|
||||
if previous_filter_names.contains(&filter_name) {
|
||||
return Err(ImageFilterFromStringError::FilterUsedMoreThanOnce { name: filter_name });
|
||||
} else {
|
||||
previous_filter_names.push(filter_name.clone());
|
||||
}
|
||||
|
||||
let value_is_present = optional_raw_value.is_some();
|
||||
let use_bool_value = || -> Result<bool, ImageFilterFromStringError> {
|
||||
if value_is_present {
|
||||
Err(ImageFilterFromStringError::FilterValueIgnored {
|
||||
filter_name: filter_name.clone(),
|
||||
})
|
||||
} else {
|
||||
Ok(true)
|
||||
}
|
||||
};
|
||||
|
||||
let use_raw_value = || -> Result<String, ImageFilterFromStringError> {
|
||||
optional_raw_value.ok_or_else(|| ImageFilterFromStringError::FilterValueMissing {
|
||||
filter_name: filter_name.clone(),
|
||||
|
@ -88,51 +124,42 @@ impl FromStr for ImageFilter {
|
|||
};
|
||||
|
||||
match filter_name.as_str() {
|
||||
"scale" | "alpha" | "blur" => {
|
||||
if let Some(transform) = transforms.first() {
|
||||
return Err(ImageFilterFromStringError::SingletonSpecifiedAfterTransform {
|
||||
singleton: filter_name.clone(),
|
||||
transform: transform.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
let (mut variable, check_range, range_string): (Option<f32>, Box<dyn Fn(&f32) -> bool>, &'static str) = match filter_name.as_str() {
|
||||
"scale" => (scale, Box::new(|v| (0.0..=10.0_f32).contains(v)), "0..=10"),
|
||||
"alpha" => (alpha, Box::new(|v| (0.0..1.0_f32).contains(v)), "0..<1"),
|
||||
"blur" => (blur, Box::new(|v| (0.0..=10.0_f32).contains(v)), "0..=10"),
|
||||
_ => unreachable!("filter_name is narrowed down in the outer match statement"),
|
||||
};
|
||||
|
||||
if variable.is_some() {
|
||||
return Err(ImageFilterFromStringError::SingletonSpecifiedMoreThanOnce { name: filter_name.clone() });
|
||||
}
|
||||
|
||||
"crop_original" => result.crop_original = Some(parse_rect_filter_value(&filter_name, use_raw_value()?)?),
|
||||
"scale" => result.scale = parse_f32_filter_value(&filter_name, use_raw_value()?, "0..=100", Box::new(|v| (0.0..=100.0).contains(v)))?,
|
||||
"rotate" => {
|
||||
let raw_value = use_raw_value()?;
|
||||
let value = f32::from_str(&raw_value).map_err(|_| ImageFilterFromStringError::FilterValueNotParsable {
|
||||
filter_name: filter_name.clone(),
|
||||
let value = i32::from_str(&raw_value).map_err(|_| ImageFilterFromStringError::FilterValueNotParsable {
|
||||
filter_name: filter_name.to_string(),
|
||||
raw_value,
|
||||
})?;
|
||||
|
||||
if !check_range(&value) {
|
||||
return Err(ImageFilterFromStringError::FilterValueNotInRange {
|
||||
filter_name: filter_name.clone(),
|
||||
range: range_string.to_owned(),
|
||||
value: value.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let _ = variable.insert(value);
|
||||
result.clockwise_quarter_rotations = match value {
|
||||
0 => 0,
|
||||
90 => 1,
|
||||
180 => 2,
|
||||
270 | -90 => 3,
|
||||
_ => return Err(ImageFilterFromStringError::RotationNotAllowed),
|
||||
};
|
||||
}
|
||||
"crop" => result.crop = Some(parse_rect_filter_value(&filter_name, use_raw_value()?)?),
|
||||
"color" => {
|
||||
let raw_value = use_raw_value()?;
|
||||
|
||||
result.color = Some(
|
||||
parse_rgb8_from_hex_str(&raw_value).map_err(|_| ImageFilterFromStringError::FilterValueNotParsable {
|
||||
filter_name: filter_name.to_string(),
|
||||
raw_value,
|
||||
})?,
|
||||
)
|
||||
}
|
||||
"alpha" => result.alpha = parse_f32_filter_value(&filter_name, use_raw_value()?, "0..<1", Box::new(|v| (0.0..1.0).contains(v)))?,
|
||||
"blur" => result.blur = parse_f32_filter_value(&filter_name, use_raw_value()?, "0..=10", Box::new(|v| (0.0..=10.0).contains(v)))?,
|
||||
"grayscale" => result.grayscale = use_bool_value()?,
|
||||
"invert" => result.invert = use_bool_value()?,
|
||||
_ => return Err(ImageFilterFromStringError::UnknownFilter { name: filter_name }),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Ok(ImageFilter {
|
||||
scale: scale.unwrap_or(1.0),
|
||||
alpha: alpha.unwrap_or(1.0),
|
||||
blur: blur.unwrap_or(0.0),
|
||||
transforms,
|
||||
})
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,2 +1,4 @@
|
|||
mod deckster_file;
|
||||
mod image_filter;
|
||||
mod image_filter;
|
||||
mod geometry;
|
||||
mod rgb;
|
37
deckster/src/model/rgb.rs
Normal file
37
deckster/src/model/rgb.rs
Normal file
|
@ -0,0 +1,37 @@
|
|||
use rgb::{RGB8, RGBA8};
|
||||
|
||||
pub fn parse_rgb8_from_hex_str(s: &str) -> Result<RGB8, ()> {
|
||||
let first_index = if s.starts_with('#') { 1 } else { 0 };
|
||||
if s.len() - first_index == 6 {
|
||||
let r = u8::from_str_radix(&s[first_index..(first_index + 2)], 16).map_err(|_| ())?;
|
||||
let g = u8::from_str_radix(&s[(first_index + 2)..(first_index + 4)], 16).map_err(|_| ())?;
|
||||
let b = u8::from_str_radix(&s[(first_index + 4)..(first_index + 6)], 16).map_err(|_| ())?;
|
||||
|
||||
return Ok(RGB8::new(r, g, b));
|
||||
}
|
||||
|
||||
Err(())
|
||||
}
|
||||
|
||||
pub fn parse_rgba8_from_hex_str(s: &str) -> Result<RGBA8, ()> {
|
||||
let first_index = if s.starts_with('#') { 1 } else { 0 };
|
||||
if s.len() - first_index == 8 {
|
||||
let r = u8::from_str_radix(&s[first_index..(first_index + 2)], 16).map_err(|_| ())?;
|
||||
let g = u8::from_str_radix(&s[(first_index + 2)..(first_index + 4)], 16).map_err(|_| ())?;
|
||||
let b = u8::from_str_radix(&s[(first_index + 4)..(first_index + 6)], 16).map_err(|_| ())?;
|
||||
let a = u8::from_str_radix(&s[(first_index + 6)..(first_index + 8)], 16).map_err(|_| ())?;
|
||||
|
||||
return Ok(RGBA8::new(r, g, b, a));
|
||||
}
|
||||
|
||||
Err(())
|
||||
}
|
||||
|
||||
pub fn parse_rgb8_with_optional_alpha_from_hex_str(s: &str, fallback_alpha: u8) -> Result<RGBA8, ()> {
|
||||
// optionally +1 for the '#'
|
||||
match s.len() {
|
||||
6 | 7 => Ok(parse_rgb8_from_hex_str(s)?.alpha(fallback_alpha)),
|
||||
8 | 9 => Ok(parse_rgba8_from_hex_str(s)?),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue