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"
|
checksum = "05aaa8004b64fd573fc9d002f4e632d51ad4f026c2b5ba95fcb6c2f32c2c47d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
@ -8,7 +8,7 @@ color-eyre = "0.6.2"
|
||||||
humantime-serde = "1.1.1"
|
humantime-serde = "1.1.1"
|
||||||
loupedeck_serial = { path = "../loupedeck_serial" }
|
loupedeck_serial = { path = "../loupedeck_serial" }
|
||||||
piet = "0.6.2"
|
piet = "0.6.2"
|
||||||
rgb = "0.8.37"
|
rgb = { version = "0.8.37", features = ["serde"] }
|
||||||
serde = { version = "1.0.193", features = ["derive"] }
|
serde = { version = "1.0.193", features = ["derive"] }
|
||||||
serde_regex = "1.1.0"
|
serde_regex = "1.1.0"
|
||||||
serde_with = "3.4.0"
|
serde_with = "3.4.0"
|
||||||
|
|
|
@ -3,7 +3,7 @@ icon = "@ph/microphone-light"
|
||||||
mode.audio_volume.mode = "input"
|
mode.audio_volume.mode = "input"
|
||||||
mode.audio_volume.regex = "Microphone"
|
mode.audio_volume.regex = "Microphone"
|
||||||
mode.audio_volume.label.muted = "Muted"
|
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.color = "#ffffff"
|
||||||
mode.audio_volume.circle_indicator.width = 2
|
mode.audio_volume.circle_indicator.width = 2
|
||||||
mode.audio_volume.circle_indicator.radius = 40
|
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 piet::kurbo::Rect;
|
||||||
|
use rgb::RGB8;
|
||||||
use serde_with::DeserializeFromStr;
|
use serde_with::DeserializeFromStr;
|
||||||
use std::fmt::{Display, Formatter, Write};
|
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use thiserror::Error;
|
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)]
|
#[derive(DeserializeFromStr)]
|
||||||
pub struct ImageFilter {
|
pub struct ImageFilter {
|
||||||
|
crop_original: Option<Rect>, // applied before scale and rotate
|
||||||
scale: f32,
|
scale: f32,
|
||||||
|
clockwise_quarter_rotations: u8,
|
||||||
|
crop: Option<Rect>, // applied after scale and rotate
|
||||||
|
color: Option<RGB8>,
|
||||||
alpha: f32,
|
alpha: f32,
|
||||||
blur: f32,
|
blur: f32,
|
||||||
transforms: Vec<ImageTransform>,
|
grayscale: bool,
|
||||||
|
invert: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
|
@ -43,17 +25,17 @@ pub enum ImageFilterFromStringError {
|
||||||
UnknownFilter { name: String },
|
UnknownFilter { name: String },
|
||||||
|
|
||||||
#[error("Filter {name} can only be used once.")]
|
#[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}.")]
|
#[error("Only the following values are for rotate: -90, 90, 180, 270")]
|
||||||
SingletonSpecifiedAfterTransform { singleton: String, transform: ImageTransform },
|
RotationNotAllowed,
|
||||||
|
|
||||||
#[error("{quarter_clockwise_turns} 90° turns can be simplified.")]
|
|
||||||
PointlessRotation { quarter_clockwise_turns: u8 },
|
|
||||||
|
|
||||||
#[error("Filter {filter_name} requires a value ({filter_name}=<value>).")]
|
#[error("Filter {filter_name} requires a value ({filter_name}=<value>).")]
|
||||||
FilterValueMissing { filter_name: String },
|
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}")]
|
#[error("The following value supplied to {filter_name} could not be parsed: {raw_value}")]
|
||||||
FilterValueNotParsable { filter_name: String, raw_value: String },
|
FilterValueNotParsable { filter_name: String, raw_value: String },
|
||||||
|
|
||||||
|
@ -64,14 +46,51 @@ pub enum ImageFilterFromStringError {
|
||||||
impl FromStr for ImageFilter {
|
impl FromStr for ImageFilter {
|
||||||
type Err = ImageFilterFromStringError;
|
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 filters: Vec<&str> = s.split('|').map(|f| f.trim()).collect();
|
||||||
|
|
||||||
let scale: Option<f32> = None;
|
let mut result = ImageFilter {
|
||||||
let alpha: Option<f32> = None;
|
crop_original: None,
|
||||||
let blur: Option<f32> = 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 {
|
for filter in filters {
|
||||||
let split_filter = filter.split_once('=');
|
let split_filter = filter.split_once('=');
|
||||||
|
@ -81,6 +100,23 @@ impl FromStr for ImageFilter {
|
||||||
(filter.to_owned(), None)
|
(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> {
|
let use_raw_value = || -> Result<String, ImageFilterFromStringError> {
|
||||||
optional_raw_value.ok_or_else(|| ImageFilterFromStringError::FilterValueMissing {
|
optional_raw_value.ok_or_else(|| ImageFilterFromStringError::FilterValueMissing {
|
||||||
filter_name: filter_name.clone(),
|
filter_name: filter_name.clone(),
|
||||||
|
@ -88,51 +124,42 @@ impl FromStr for ImageFilter {
|
||||||
};
|
};
|
||||||
|
|
||||||
match filter_name.as_str() {
|
match filter_name.as_str() {
|
||||||
"scale" | "alpha" | "blur" => {
|
"crop_original" => result.crop_original = Some(parse_rect_filter_value(&filter_name, use_raw_value()?)?),
|
||||||
if let Some(transform) = transforms.first() {
|
"scale" => result.scale = parse_f32_filter_value(&filter_name, use_raw_value()?, "0..=100", Box::new(|v| (0.0..=100.0).contains(v)))?,
|
||||||
return Err(ImageFilterFromStringError::SingletonSpecifiedAfterTransform {
|
"rotate" => {
|
||||||
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() });
|
|
||||||
}
|
|
||||||
|
|
||||||
let raw_value = use_raw_value()?;
|
let raw_value = use_raw_value()?;
|
||||||
let value = f32::from_str(&raw_value).map_err(|_| ImageFilterFromStringError::FilterValueNotParsable {
|
let value = i32::from_str(&raw_value).map_err(|_| ImageFilterFromStringError::FilterValueNotParsable {
|
||||||
filter_name: filter_name.clone(),
|
filter_name: filter_name.to_string(),
|
||||||
raw_value,
|
raw_value,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if !check_range(&value) {
|
result.clockwise_quarter_rotations = match value {
|
||||||
return Err(ImageFilterFromStringError::FilterValueNotInRange {
|
0 => 0,
|
||||||
filter_name: filter_name.clone(),
|
90 => 1,
|
||||||
range: range_string.to_owned(),
|
180 => 2,
|
||||||
value: value.to_string(),
|
270 | -90 => 3,
|
||||||
});
|
_ => return Err(ImageFilterFromStringError::RotationNotAllowed),
|
||||||
}
|
};
|
||||||
|
|
||||||
let _ = variable.insert(value);
|
|
||||||
}
|
}
|
||||||
|
"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 }),
|
_ => return Err(ImageFilterFromStringError::UnknownFilter { name: filter_name }),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(ImageFilter {
|
Ok(result)
|
||||||
scale: scale.unwrap_or(1.0),
|
|
||||||
alpha: alpha.unwrap_or(1.0),
|
|
||||||
blur: blur.unwrap_or(0.0),
|
|
||||||
transforms,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,2 +1,4 @@
|
||||||
mod deckster_file;
|
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