diff --git a/Cargo.toml b/Cargo.toml index 1be9b40..62682a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,10 @@ scraper = "0.19" # Discord serenity = "=0.12.4" +# Image processing +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] } +color-thief = "0.2.2" + # Utils uuid = { version = "1.11.1", features = ["v4"] } chrono = "0.4.40" diff --git a/src/discord/api.rs b/src/discord/api.rs index fe1b0d4..734a381 100644 --- a/src/discord/api.rs +++ b/src/discord/api.rs @@ -57,6 +57,8 @@ pub enum DiscordError { Api, } +const FALLBACK_EMBED_COLOR: u32 = 0x005eeb; + impl DiscordAPI { pub async fn default() -> Self { DiscordAPI::new( @@ -107,7 +109,7 @@ impl DiscordAPI { info!(channel_id = %channel_id, event = %event.title, "Sending event"); let title = event.title.clone(); - let embed = Self::build_event_embed(event, ticket_shop_url, ticket_shop_icon_url); + let embed = Self::build_event_embed(event, ticket_shop_url, ticket_shop_icon_url).await; let message_builder = CreateMessage::new().add_embed(embed.clone()); @@ -120,7 +122,7 @@ impl DiscordAPI { }) } - fn build_event_embed( + async fn build_event_embed( event: Event, ticket_shop_url: Option, ticket_shop_icon_url: &str, @@ -138,17 +140,57 @@ impl DiscordAPI { } let embed_description = Self::truncate_embed_description(description); + let color = Self::get_image_dominant_color(&event.details.image_url) + .await + .unwrap_or_else(|| Colour::new(FALLBACK_EMBED_COLOR)); CreateEmbed::new() .title(event.title) .url(event.link) .description(embed_description) .author(author) - .color(Colour::new(0x005eeb)) + .color(color) .field("Datas", event.occurring_at.dates, true) .image(event.details.image_url) } + pub async fn get_image_dominant_color(image_url: &str) -> Option { + let bytes = reqwest::get(image_url) + .await + .map_err(|e| warn!("Failed fetching event image '{}': {}", image_url, e)) + .ok()? + .bytes() + .await + .map_err(|e| warn!("Failed reading event image bytes '{}': {}", image_url, e)) + .ok()?; + + let rgba = image::load_from_memory(&bytes) + .map_err(|e| warn!("Failed decoding event image '{}': {}", image_url, e)) + .ok()? + .to_rgba8(); + + let palette = + color_thief::get_palette(rgba.as_raw(), color_thief::ColorFormat::Rgba, 10, 8) + .map_err(|e| warn!("Failed extracting palette for '{}': {:?}", image_url, e)) + .ok()?; + + let dominant = palette + .iter() + .find(|c| Self::is_colorful(c.r, c.g, c.b)) + .or_else(|| palette.first())?; + + Some(Colour::from_rgb(dominant.r, dominant.g, dominant.b)) + } + + /// Excludes near-grayscale colors so a vivid palette entry wins over a washed-out background. + fn is_colorful(r: u8, g: u8, b: u8) -> bool { + const SATURATION_THRESHOLD: u8 = 30; + let max = r.max(g).max(b); + let min = r.min(g).min(b); + + max - min > SATURATION_THRESHOLD + } + pub async fn add_custom_reaction(&self, message: &Message, emoji: &EmojiConfig) { trace!("Adding reaction"); @@ -618,9 +660,9 @@ impl DiscordAPI { Ok(false) => {} } - let mut embed = - Self::build_event_embed(event, ticket_shop_url, ticket_shop_icon_url) - .field("Voto", vote_emoji.to_string(), true); + let mut embed = Self::build_event_embed(event, ticket_shop_url, ticket_shop_icon_url) + .await + .field("Voto", vote_emoji.to_string(), true); if let Some(comment) = comment { embed = embed.field("Comentários", comment, true); diff --git a/tests/discord_embed_color_tests.rs b/tests/discord_embed_color_tests.rs new file mode 100644 index 0000000..5b2ace2 --- /dev/null +++ b/tests/discord_embed_color_tests.rs @@ -0,0 +1,62 @@ +mod discord_embed_color { + use alertaemcena::agenda_cultural::api::AgendaCulturalAPI; + use alertaemcena::discord::api::DiscordAPI; + use serenity::all::Colour; + + async fn assert_dominant_color_extracted(event_url: &str, expected_hex: u32) { + let event = AgendaCulturalAPI::scrape_event(event_url) + .await + .unwrap_or_else(|| panic!("Failed to scrape event '{}'", event_url)); + + let color = DiscordAPI::get_image_dominant_color(&event.details.image_url).await; + + match color { + Some(color) => assert_eq!( + color, + Colour::new(expected_hex), + "Dominant color for '{}' did not match expected", + event.details.image_url + ), + None => panic!( + "Failed to extract dominant color from event image '{}'", + event.details.image_url + ), + } + } + + #[test_log::test(tokio::test)] + async fn should_extract_dominant_color_for_sonho_de_uma_noite_de_verao() { + assert_dominant_color_extracted( + "https://www.agendalx.pt/events/event/sonho-de-uma-noite-de-verao-5/", + 0x1B80B4, + ) + .await; + } + + #[test_log::test(tokio::test)] + async fn should_extract_dominant_color_for_o_filho() { + assert_dominant_color_extracted( + "https://www.agendalx.pt/events/event/o-filho-2/", + 0x785D55, + ) + .await; + } + + #[test_log::test(tokio::test)] + async fn should_extract_dominant_color_for_o_coracao_de_um_pugilista() { + assert_dominant_color_extracted( + "https://www.agendalx.pt/events/event/o-coracao-de-um-pugilista/", + 0xCEB2A7, + ) + .await; + } + + #[test_log::test(tokio::test)] + async fn should_extract_dominant_color_for_a_ratoeira() { + assert_dominant_color_extracted( + "https://www.agendalx.pt/events/event/a-ratoeira-5/", + 0xC07A4F, + ) + .await; + } +}