smol bot
sorters
| -rw-r--r-- | Cargo.toml | 5 | ||||
| -rw-r--r-- | src/bot/map.rs | 81 | ||||
| -rw-r--r-- | src/bot/mod.rs | 5 | ||||
| -rw-r--r-- | src/bot/sorter.rs | 176 | ||||
| -rw-r--r-- | src/bot/usage.md | 4 | ||||
| -rw-r--r-- | src/main.rs | 3 |
6 files changed, 255 insertions, 19 deletions
@@ -29,7 +29,7 @@ flate2 = { version = "1.0", features = [ lemu = { features = ["diagnose"], default-features = false, version = "0.2.0" } dashmap = "5.5.3" oxipng = { version = "9.0.0", default-features = false } -fimg = "0.4.26" +fimg = { version = "0.4.26", features = ["save"] } phf = { version = "0.11.2", features = ["macros"] } emojib = { git = "https://github.com/Apricot-Conservation-Project/emoji", package = "emoji" } rust-fuzzy-search = "0.1.1" @@ -52,6 +52,9 @@ httpdate = "1.0.3" pollster = "0.3.0" btparse-stable = "0.1.2" cpu-monitor = "0.1.1" +exoquant = "0.2.0" +image = { version = "0.25.5", features = ["bmp", "jpeg", "png", "webp"], default-features = false } +car = "0.1.1" [build-dependencies] emojib = { git = "https://github.com/Apricot-Conservation-Project/emoji", features = [ diff --git a/src/bot/map.rs b/src/bot/map.rs index 4188fc9..0c6e3aa 100644 --- a/src/bot/map.rs +++ b/src/bot/map.rs @@ -97,25 +97,74 @@ async fn render(m: Map, deser_took: Duration) -> (Timings, Vec<u8>) { .unwrap() } +pub async fn find( + msg: &Message, + c: &serenity::client::Context, +) -> Result<Option<(String, Map, Duration)>> { + match scour(msg).await? { + None => Ok(None), + Some((Err(e), _)) => { + msg.reply(c, string(e)).await?; + Ok(None) + } + Some((Ok(m), deser_took)) => Ok(Some(( + msg.author_nick(c).await.unwrap_or(msg.author.name.clone()), + m, + deser_took, + ))), + } +} + pub async fn with(msg: &Message, c: &serenity::client::Context) -> Result<()> { - let auth = msg.author_nick(c).await.unwrap_or(msg.author.name.clone()); - let (m, deser_took) = match scour(msg).await? { - None => return Ok(()), - Some((Err(e), _)) => return Ok(drop(msg.reply(c, string(e)).await?)), - Some((Ok(m), deser_took)) => (m, deser_took), + let Some((auth, m, deser_took)) = find(msg, c).await? else { + return Ok(()); }; let t = msg.channel_id.start_typing(&c.http); - let name = strip_colors(m.tags.get("name").or(m.tags.get("mapname")).unwrap()); - let ( - Timings { - deser_took, - render_took, - compression_took, - total, - }, - png, - ) = render(m, deser_took).await; + let (png, embed) = embed(m, &auth, deser_took).await; t.stop(); - msg.channel_id.send_message(c,CreateMessage::new().add_file(CreateAttachment::bytes(png,"map.png")).embed(CreateEmbed::new().title(&name).footer(CreateEmbedFooter::new(format!("render of {name} (requested by {auth}) took: {:.3}s (deser: {}ms, render: {:.3}s, compression: {:.3}s)", total.as_secs_f32(), deser_took.as_millis(), render_took.as_secs_f32(), compression_took.as_secs_f32()))).attachment("map.png").color(SUCCESS))).await?; + msg.channel_id + .send_message(c, CreateMessage::new().add_file(png).embed(embed)) + .await?; + Ok(()) +} + +async fn embed(m: Map, auth: &str, deser_took: Duration) -> (CreateAttachment, CreateEmbed) { + let name = strip_colors(m.tags.get("name").or(m.tags.get("mapname")).unwrap()); + let (timings, png) = render(m, deser_took).await; + ( + CreateAttachment::bytes(png, "map.png"), + CreateEmbed::new() + .title(&name) + .footer(footer((&name, auth), timings)) + .attachment("map.png") + .color(SUCCESS), + ) +} + +fn footer( + (name, auth): (&str, &str), + Timings { + deser_took, + render_took, + compression_took, + total, + }: Timings, +) -> CreateEmbedFooter { + CreateEmbedFooter::new(format!("render of {name} (requested by {auth}) took: {:.3}s (deser: {}ms, render: {:.3}s, compression: {:.3}s)", total.as_secs_f32(), deser_took.as_millis(), render_took.as_secs_f32(), compression_took.as_secs_f32())) +} + +#[poise::command( + context_menu_command = "Render map", + install_context = "User", + interaction_context = "Guild|PrivateChannel" +)] +/// Renders map inside a message. +pub async fn render_message(c: super::Context<'_>, m: Message) -> Result<()> { + let Some((auth, m, deser_took)) = find(&m, c.serenity_context()).await? else { + poise::say_reply(c, "no map").await?; + return Ok(()); + }; + let (png, embed) = embed(m, &auth, deser_took).await; + poise::send_reply(c, CreateReply::default().attachment(png).embed(embed)).await?; Ok(()) } diff --git a/src/bot/mod.rs b/src/bot/mod.rs index ff02b4c..f288db1 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -4,6 +4,7 @@ pub mod ownership; mod repos; mod schematic; pub mod search; +mod sorter; use crate::emoji; use anyhow::Result; @@ -263,7 +264,7 @@ impl Bot { std::env::var("TOKEN").unwrap_or_else(|_| read_to_string("token").expect("wher token")); let f = poise::Framework::builder() .options(poise::FrameworkOptions { - commands: vec![logic::run(), lb(), logic::run_file(), schembrowser_instructions(), lb_no_vds(), ping(), help(), scour(), search::search(), search::file(), render(), render_file(), render_message()], + commands: vec![logic::run(), lb(), logic::run_file(), sorter::sorter(), schembrowser_instructions(), lb_no_vds(), ping(), help(), scour(), search::search(), search::file(), render(), render_file(), render_message(), map::render_message()], event_handler: |c, e, _, d| { Box::pin(async move { match e { @@ -428,7 +429,9 @@ impl Bot { schembrowser_instructions(), render_file(), render_message(), + map::render_message(), logic::run_file(), + sorter::sorter(), ], ) .await?; diff --git a/src/bot/sorter.rs b/src/bot/sorter.rs new file mode 100644 index 0000000..c35f1a3 --- /dev/null +++ b/src/bot/sorter.rs @@ -0,0 +1,176 @@ +use anyhow::Result; +use atools::prelude::*; +use block::SORTER; +use exoquant::{ + ditherer::{self, Ditherer}, + Color, Remapper, SimpleColorSpace, +}; +use fimg::Image; +use mindus::*; +use poise::{serenity_prelude::*, ChoiceParameter}; + +#[derive(ChoiceParameter)] +enum Scaling { + /// dumbest, jaggedest, scaling algorithm + #[name = "nearest"] + Nearest, + /// prettiest scaling algorithm + #[name = "lanczos 3"] + Lanczos3, + #[name = "box"] + Box, + #[name = "bilinear"] + Bilinear, + #[name = "hamming"] + Hamming, + #[name = "catmull rom"] + CatmullRom, + #[name = "mitchell"] + Mitchell, +} + +#[derive(ChoiceParameter)] +enum Dithering { + #[name = "floyd steinberg"] + FloydSteinberg, + #[name = "ordered"] + /// A 2x2 ordered dithering. + Ordered, +} + +fn sort( + mut x: Image<Box<[u8]>, 4>, + height: Option<u8>, + width: Option<u8>, + algorithm: Option<Scaling>, + dithered: Option<Dithering>, +) -> (Image<Box<[u8]>, 4>, Schematic) { + const PAL: [Color; 23] = car::map!( + [0, 0, 0, 0].join(car::map!( + car::map!(mindus::item::Type::ALL, |i| i.color()), + |(r, g, b)| [r, g, b, 255] + )), + |[r, g, b, a]| Color { r, g, b, a } + ); + + if width.is_some() || height.is_some() { + let width = width.map(|x| x as u32).unwrap_or(x.width()); + let height = height.map(|x| x as u32).unwrap_or(x.height()); + x = match algorithm.unwrap_or(Scaling::Nearest) { + Scaling::Nearest => x.scale::<fimg::scale::Nearest>(width, height), + Scaling::Lanczos3 => x.scale::<fimg::scale::Lanczos3>(width, height), + Scaling::Box => x.scale::<fimg::scale::Box>(width, height), + Scaling::Bilinear => x.scale::<fimg::scale::Bilinear>(width, height), + Scaling::Hamming => x.scale::<fimg::scale::Hamming>(width, height), + Scaling::CatmullRom => x.scale::<fimg::scale::CatmullRom>(width, height), + Scaling::Mitchell => x.scale::<fimg::scale::Mitchell>(width, height), + }; + }; + + fn quant(x: Image<&[u8], 4>, d: impl Ditherer) -> Vec<u8> { + Remapper::new(&PAL, &SimpleColorSpace::default(), &d).remap( + &x.chunked() + .map(|&[r, g, b, a]| Color::new(r, g, b, a)) + .collect::<Vec<_>>(), + x.width() as usize, + ) + } + + let quant = match dithered { + Some(Dithering::FloydSteinberg) => quant(x.as_ref(), ditherer::FloydSteinberg::vanilla()), + Some(Dithering::Ordered) => quant(x.as_ref(), ditherer::Ordered), + None => quant(x.as_ref(), ditherer::None), + }; + + let (width, height) = (x.width() as usize, x.height() as usize); + let mut s = Schematic::new(width, height); + let pixels = (0..width) + .flat_map(|x_| (0..height).map(move |y| (x_, y))) + .filter_map( + move |(x_, y_)| match quant[(height - y_ - 1) * width + x_] { + 0 => None, + x => Some(((x_, y_), x - 1)), + }, + ); + for ((x, y), i) in pixels.clone() { + s.set( + x, + y, + &SORTER, + data::dynamic::DynData::Content(mindus::content::Type::Item, i as _), + block::Rotation::Up, + ) + .unwrap(); + } + let mut preview = Image::build(x.width(), x.height()).alloc(); + for ((x, y), i) in pixels { + unsafe { + preview.set_pixel( + x as _, + (height - y - 1) as _, + mindus::item::Type::ALL[i as usize] + .color() + .array() + .join(255), + ) + }; + } + ( + preview.scale::<fimg::scale::Nearest>(preview.width() * 4, preview.height() * 4), + s, + ) +} + +#[poise::command(slash_command)] +/// Create sorter representations of images. +pub async fn sorter( + c: super::Context<'_>, + #[description = "image: png, webp, jpg"] i: Attachment, + #[description = "height in blocks"] height: Option<u8>, + #[description = "height in blocks"] width: Option<u8>, + #[description = "scaling algorithm, defaults to nearest"] algorithm: Option<Scaling>, + #[description = "dithering algorithm, defaults to none"] dithered: Option<Dithering>, +) -> Result<()> { + c.defer().await?; + let image = i.download().await?; + match image::load_from_memory(&image) { + Ok(x) => { + let x = x.to_rgba8(); + let (preview, mut schem) = sort( + Image::<_, 4>::build(x.width(), x.height()) + .buf(x.into_vec()) + .boxed(), + width, + height, + algorithm, + dithered, + ); + use crate::emoji::to_mindustry::named::*; + schem + .tags + .insert("labels".to_string(), format!(r#"["{SORTER}"]"#)); + let mut h = std::hash::DefaultHasher::default(); + std::hash::Hasher::write(&mut h, preview.bytes()); + let h = std::hash::Hasher::finish(&h) as u32; + schem + .tags + .insert("name".to_string(), format!("{SORTER} #{h:x}")); + let mut buff = data::DataWrite::default(); + schem.serialize(&mut buff)?; + let buff = buff.consume(); + let mut preview_png = Vec::with_capacity(1 << 11); + fimg::WritePng::write(&preview, &mut preview_png).unwrap(); + poise::send_reply( + c, + poise::CreateReply::default() + .attachment(CreateAttachment::bytes(preview_png, "preview.png")) + .attachment(CreateAttachment::bytes(buff, format!("sorter{h:x}.msch"))), + ) + .await?; + } + Err(e) => { + c.reply(e.to_string()).await?; + } + } + Ok(()) +} diff --git a/src/bot/usage.md b/src/bot/usage.md index edf534e..f091955 100644 --- a/src/bot/usage.md +++ b/src/bot/usage.md @@ -6,6 +6,8 @@ you may instead upload a message containing a base64 encoded schematic. you can also upload maps, eg `salt_flats.msav`. commands: + - `eval`: executes mlog. see `/help eval` for more info. +- `sorter`: creates sorter representations of images. -bugs to be reported [here](<https://github.com/bend-n/mindus/issues/new>), or ping <@696196765564534825>.
\ No newline at end of file +bugs to be reported [here](https://github.com/bend-n/mindus/issues/new), or ping <@696196765564534825>. diff --git a/src/main.rs b/src/main.rs index b5999a4..4963eae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,8 @@ +#![allow(incomplete_features)] #![feature( let_chains, + generic_const_exprs, + effects, lazy_cell_consume, iter_intersperse, if_let_guard, |