Compare commits
14 Commits
270878e4fa
...
meta
| Author | SHA1 | Date | |
|---|---|---|---|
| 98f4a20de6 | |||
| 9591dcdd3e | |||
| d0f0d07b51 | |||
| 6ee36cb65a | |||
| d3ad04bc33 | |||
| 42270b18f0 | |||
| d6ea8977a6 | |||
| 5da8b2b193 | |||
| 158633d077 | |||
| 045d19c4c8 | |||
| 4841ab9f06 | |||
| fd87302642 | |||
| 41fbb5dbdf | |||
| b26b94dfa6 |
@@ -1,3 +1,10 @@
|
||||
DISCORD_TOKEN=
|
||||
LASTFM_API_KEY=
|
||||
DATABASE_URL=sqlite:watcat.db
|
||||
|
||||
# optional, for spotify support
|
||||
RSPOTIFY_CLIENT_ID=
|
||||
RSPOTIFY_CLIENT_SECRET=
|
||||
|
||||
# optional, to set color cache size. set to 0 to disable
|
||||
MAX_CACHE_SIZE=
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
/.env
|
||||
/watcat.db*
|
||||
/http-cacache
|
||||
/overrides.txt
|
||||
|
||||
Generated
+851
-9
File diff suppressed because it is too large
Load Diff
+7
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "watcat"
|
||||
version = "0.1.0"
|
||||
version = "0.6.2"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
@@ -11,6 +11,12 @@ reqwest = "0.12"
|
||||
reqwest-middleware = "0.4.2"
|
||||
http-cache-reqwest = "0.15.1"
|
||||
palette = "0.7.6"
|
||||
rspotify = "0.15.0"
|
||||
# text rendering dependencies
|
||||
arabic_reshaper = "0.4.2"
|
||||
unicode-bidi = "0.3.18"
|
||||
image = "0.25.6"
|
||||
imagetext = { git = "https://github.com/nathanielfernandes/imagetext" }
|
||||
|
||||
[dependencies.magick_rust]
|
||||
path = "./magick-rust"
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
Copyright (c) 2025, Seoxi Ryouko
|
||||
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
* Neither the name of watcat nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||||
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -1,6 +1,5 @@
|
||||
watcat
|
||||
kirara
|
||||
======
|
||||
|
||||
rust clone of https://github.com/adamanldo/Cosmo
|
||||
|
||||
why?
|
||||
@@ -8,7 +7,7 @@ why?
|
||||
|
||||
cosmo relies on [imagetext-py](https://github.com/nathanielfernandes/imagetext-py), which either does not have a freebsd wheel or else uv cannot find it
|
||||
|
||||
~~also I'm a rust shill and python hater and just needed an excuse~~
|
||||
now it seems that magick-rust can't link on freebsd... guess I'll die
|
||||
|
||||
but your code sucks
|
||||
-------------------
|
||||
@@ -28,4 +27,14 @@ init the database ``sqlx migrate run``
|
||||
TODO
|
||||
----
|
||||
|
||||
literally just add the text
|
||||
+ ~~add art and lastfm url fetching~~
|
||||
+ ~~add help command~~
|
||||
+ ~~refactor to combine repeated parts of art, url, and fmi.~~
|
||||
+ ~~use macro for commands~~
|
||||
+ ~~use Option instead of empty string sentinel value~~
|
||||
+ ~~add cache for album art colors~~
|
||||
+ ~~add spotify link fetching~~
|
||||
+ ~~add arbitrary spotify searching~~
|
||||
+ ~~use static seed for kmeans~~
|
||||
+ make various parameters user-configurable
|
||||
+ use tiny-skia instead of magick-rust?
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 4.1 KiB |
+511
-116
@@ -2,7 +2,7 @@ extern crate dotenvy;
|
||||
extern crate lastfm;
|
||||
extern crate reqwest;
|
||||
extern crate reqwest_middleware;
|
||||
use http_cache_reqwest::{CACacheManager, Cache, CacheMode, HttpCache, HttpCacheOptions};
|
||||
use http_cache_reqwest::{CACacheManager, Cache, CacheMode, HttpCache, HttpCacheOptions,};
|
||||
|
||||
use serenity::async_trait;
|
||||
use serenity::builder::{CreateAttachment, CreateMessage};
|
||||
@@ -11,10 +11,15 @@ use serenity::model::id::UserId;
|
||||
use serenity::prelude::*;
|
||||
|
||||
extern crate magick_rust;
|
||||
use magick_rust::{ColorspaceType, CompositeOperator, MagickWand, PixelWand, magick_wand_genesis};
|
||||
use magick_rust::{
|
||||
ColorspaceType, CompositeOperator, FilterType, MagickWand, PixelWand, magick_wand_genesis,
|
||||
};
|
||||
use magick_rust::bindings::MagickSetSeed;
|
||||
|
||||
use std::env;
|
||||
use std::sync::Once;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Once};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use palette::color_difference::ImprovedCiede2000;
|
||||
use palette::lab::Lab;
|
||||
@@ -23,6 +28,17 @@ use palette::white_point::D65;
|
||||
extern crate sqlx;
|
||||
use sqlx::sqlite::SqlitePool;
|
||||
|
||||
use rspotify::model::{enums::types::SearchType, search::SearchResult};
|
||||
use rspotify::{ClientCredsSpotify, Credentials, clients::BaseClient};
|
||||
|
||||
use imagetext::fontdb::FontDB;
|
||||
use imagetext::superfont::SuperFont;
|
||||
|
||||
use std::time::SystemTime;
|
||||
use std::io::Read;
|
||||
|
||||
mod text;
|
||||
|
||||
static START: Once = Once::new();
|
||||
|
||||
struct Handler;
|
||||
@@ -43,13 +59,13 @@ macro_rules! now {
|
||||
|
||||
macro_rules! log {
|
||||
($a:expr) => {
|
||||
if env::var("DEBUG").unwrap_or("0".to_string()) == "1" {
|
||||
if env::var("DEBUG").unwrap_or("0".to_owned()) == "1" {
|
||||
println!(concat!("debug: {} ", $a), now!())
|
||||
}
|
||||
};
|
||||
|
||||
($a:expr, $($b:expr),+) => {
|
||||
if env::var("DEBUG").unwrap_or("0".to_string()) == "1" {
|
||||
if env::var("DEBUG").unwrap_or("0".to_owned()) == "1" {
|
||||
println!(concat!("debug: {} ", $a), now!(), $($b),+)
|
||||
}
|
||||
};
|
||||
@@ -58,8 +74,9 @@ macro_rules! log {
|
||||
macro_rules! handle_magick_result {
|
||||
($a: expr, $b: literal) => {
|
||||
match $a {
|
||||
Ok(_) => {
|
||||
Ok(a) => {
|
||||
log!("{} run successfully", stringify!($a));
|
||||
a
|
||||
}
|
||||
Err(e) => return Reply::Text(format!("Error: {} {}", $b, e)),
|
||||
}
|
||||
@@ -70,7 +87,7 @@ macro_rules! handle_magick_option {
|
||||
($a: expr, $b: literal) => {
|
||||
match $a {
|
||||
Some(res) => res,
|
||||
None => return Reply::Text($b.to_string()),
|
||||
None => return Reply::Text($b.to_owned()),
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -109,98 +126,253 @@ fn validate_color(col: &PixelWand) -> Option<Lab> {
|
||||
Some(Lab::<D65>::from_components(color_raw))
|
||||
}
|
||||
|
||||
async fn fmi(ctx: &Context, arg: &str, id: UserId, avatar: Option<String>) -> Reply {
|
||||
async fn get_track(
|
||||
ctx: &Context,
|
||||
arg: Option<&str>,
|
||||
id: UserId,
|
||||
) -> Result<lastfm::track::NowPlayingTrack, Reply> {
|
||||
let lastfm_user = match arg {
|
||||
"" => get_lastfm_username(ctx, id).await,
|
||||
_ => Some(arg.to_string()),
|
||||
None => get_lastfm_username(ctx, id).await,
|
||||
Some(user) => Some(user.to_owned()),
|
||||
};
|
||||
let lastfm_client = match lastfm_user {
|
||||
Some(s) => lastfm::Client::<String, String>::from_env(s),
|
||||
None => return Reply::Text("No last.fm username set.".to_string()),
|
||||
None => return Err(Reply::Text("No last.fm username set.".to_owned())),
|
||||
};
|
||||
let now_playing = match lastfm_client.now_playing().await {
|
||||
Ok(np) => np,
|
||||
Err(e) => return Reply::Text(format!("Error: grabbing last.fm user data failed {e}")),
|
||||
Err(e) => {
|
||||
return Err(Reply::Text(format!(
|
||||
"Error: grabbing last.fm user data failed {e}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let track = match now_playing {
|
||||
Some(track) => track,
|
||||
None => return Reply::Text("Nothing playing.".to_string()),
|
||||
None => return Err(Reply::Text("Nothing playing.".to_owned())),
|
||||
};
|
||||
let image_uri = match track.image.extralarge {
|
||||
Ok(track)
|
||||
}
|
||||
|
||||
async fn art(ctx: &Context, arg: Option<&str>, id: UserId) -> Reply {
|
||||
let track = match get_track(ctx, arg, id).await {
|
||||
Ok(track) => track,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let track_art = match track.image.extralarge {
|
||||
Some(track_art) => track_art,
|
||||
None => return Reply::Text("Error: getting image uri failed".to_owned()),
|
||||
};
|
||||
Reply::Text(track_art.to_owned())
|
||||
}
|
||||
|
||||
async fn url(ctx: &Context, arg: Option<&str>, id: UserId) -> Reply {
|
||||
let track = match get_track(ctx, arg, id).await {
|
||||
Ok(track) => track,
|
||||
Err(e) => return e,
|
||||
};
|
||||
Reply::Text(track.url)
|
||||
}
|
||||
|
||||
async fn spot(ctx: &Context, arg: Option<&str>, id: UserId) -> Reply {
|
||||
let track = match get_track(ctx, arg, id).await {
|
||||
Ok(track) => track,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let mut data = ctx.data.write().await;
|
||||
let spotify_option = data.get_mut::<SpotifyHaver>().expect("Failed to have spotify option");
|
||||
if spotify_option.is_none() {
|
||||
return Reply::Text("Unable to use Spotify commands: Contact bot Administrator.".to_owned())
|
||||
}
|
||||
let spotify_arc_mutex = spotify_option.as_mut().unwrap();
|
||||
let spotify_client = spotify_arc_mutex.lock().await;
|
||||
spotify_client.request_token().await.unwrap();
|
||||
let search = spotify_client.search(format!("{} {}", track.name, track.artist.name).as_str(), SearchType::Track, None, None, None, None).await;
|
||||
let tracks = match search {
|
||||
Ok(SearchResult::Tracks(track_page)) => track_page,
|
||||
Err(e) => return Reply::Text(format!("Failed to get track {e}")),
|
||||
_ => return Reply::Text("Spotify search failed".to_owned()),
|
||||
};
|
||||
match &tracks.items.first().map(|x| x.external_urls.get("spotify")) {
|
||||
Some(Some(url)) => Reply::Text(url.to_owned().to_owned()),
|
||||
_ => Reply::Text("Unable to get spotify url".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_overrides_inner(last_modified: Option<SystemTime>) -> Option<HashMap<String, String>> {
|
||||
let mut file = match std::fs::File::open("overrides.txt") {
|
||||
Ok(a) => a,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let file_stats = match file.metadata() {
|
||||
Ok(a) => a,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let file_modified_time = match file_stats.modified() {
|
||||
Ok(a) => a,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let reload = match last_modified {
|
||||
None => true,
|
||||
Some(last_modified) => match file_modified_time.duration_since(last_modified) {
|
||||
Err(_) => true,
|
||||
Ok(a) => a != std::time::Duration::default()
|
||||
}
|
||||
};
|
||||
if !reload { return None; }
|
||||
let mut contents = "".to_owned();
|
||||
match file.read_to_string(&mut contents) {
|
||||
Ok(_) => (),
|
||||
Err(_) => return None,
|
||||
};
|
||||
Some(contents.as_str().lines().map(|x| {
|
||||
let mut split = x.split("@");
|
||||
let a: &str = split.next().unwrap_or("").trim();
|
||||
let b: &str = split.next().unwrap_or("").trim();
|
||||
(a.to_owned(), b.to_owned())
|
||||
}).collect())
|
||||
}
|
||||
|
||||
async fn get_overrides(ctx: &Context) -> HashMap<String, String> {
|
||||
let data = ctx.data.write().await;
|
||||
let overrides = data.get::<OverridesHaver>().expect("Failed to have overrides");
|
||||
let mut map = overrides.map.lock().await;
|
||||
let new_map = get_overrides_inner(overrides.last_modified).await;
|
||||
if let Some(new_map) = new_map {
|
||||
*map = new_map;
|
||||
}
|
||||
map.clone()
|
||||
}
|
||||
|
||||
async fn fmi(ctx: &Context, arg: Option<&str>, id: UserId, avatar: Option<String>) -> Reply {
|
||||
let track = match get_track(ctx, arg, id).await {
|
||||
Ok(track) => track,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let track_info = text::TrackInfo {
|
||||
title: track.name,
|
||||
artist: track.artist.name,
|
||||
album: track.album,
|
||||
};
|
||||
let original_image_uri = match track.image.extralarge {
|
||||
Some(iu) => iu,
|
||||
None => return Reply::Text("Error: getting image uri failed".to_string()),
|
||||
None => return Reply::Text("Error: getting image uri failed".to_owned()),
|
||||
};
|
||||
|
||||
let image = match get_image(ctx, image_uri.as_str()).await {
|
||||
Ok(i) => i,
|
||||
Err(e) => return Reply::Text(format!("{}", e)),
|
||||
};
|
||||
let overrides = get_overrides(ctx).await;
|
||||
let image_uri: String = match overrides.get(&format!("{} | {}", track_info.artist, track_info.album)) {
|
||||
Some(s) => s,
|
||||
None => match overrides.get(&track_info.album) {
|
||||
Some(s) => s,
|
||||
None => &original_image_uri,
|
||||
},
|
||||
}.to_owned();
|
||||
|
||||
let mut base_color = PixelWand::new();
|
||||
let mut accent_color = PixelWand::new();
|
||||
let mut white = PixelWand::new();
|
||||
let main_wand = MagickWand::new();
|
||||
let art_wand = MagickWand::new();
|
||||
let mut art_wand_cluster = MagickWand::new();
|
||||
let mask_wand = MagickWand::new();
|
||||
let text_image_wand = MagickWand::new();
|
||||
|
||||
handle_magick_result!(
|
||||
base_color.set_color("#7f7f7f"),
|
||||
"Failed to set init base color"
|
||||
);
|
||||
handle_magick_result!(
|
||||
art_wand.read_image_blob(image),
|
||||
"Failed to read image from uri"
|
||||
);
|
||||
handle_magick_result!(
|
||||
art_wand.transform_image_colorspace(ColorspaceType::Lab),
|
||||
"Failed to set art colorspace"
|
||||
);
|
||||
let image = match get_image(ctx, image_uri.as_str()).await {
|
||||
Ok(i) => i,
|
||||
Err(e) => return Reply::Text(format!("{e}")),
|
||||
};
|
||||
handle_magick_result!(
|
||||
art_wand.read_image_blob(image),
|
||||
"Failed to read image from uri"
|
||||
);
|
||||
handle_magick_result!(
|
||||
art_wand.transform_image_colorspace(ColorspaceType::Lab),
|
||||
"Failed to set art colorspace"
|
||||
);
|
||||
|
||||
let (art_width, art_height) = (art_wand.get_image_width(), art_wand.get_image_height());
|
||||
handle_magick_result!(
|
||||
art_wand_cluster.new_image(art_width, art_height, &base_color),
|
||||
"Failed to init cluster image"
|
||||
);
|
||||
handle_magick_result!(
|
||||
art_wand_cluster.compose_images(&art_wand, CompositeOperator::SrcOver, true, 0, 0),
|
||||
"Failed to copy onto cluster image"
|
||||
);
|
||||
// least stupid way I could figure out to do this, since it seems that MagickWand::new_from_wand() doesn't work
|
||||
handle_magick_result!(
|
||||
art_wand_cluster.set_image_colorspace(ColorspaceType::Lab),
|
||||
"Failed to set cluster colorspace"
|
||||
);
|
||||
handle_magick_result!(
|
||||
art_wand_cluster.kmeans(10, 200, 0.001),
|
||||
"Failed to run kmeans"
|
||||
);
|
||||
let mut colors = handle_magick_option!(
|
||||
art_wand_cluster.get_image_histogram(),
|
||||
"Failed to get color histogram"
|
||||
);
|
||||
colors.sort_by_cached_key(|color| -(color.get_color_count() as isize));
|
||||
let base_lab = validate_color(&colors[0]).expect("Failed to validate base color");
|
||||
let other_colors = colors
|
||||
.iter()
|
||||
.filter_map(|col| {
|
||||
let testing_color = validate_color(col)?;
|
||||
match testing_color.improved_difference(base_lab) {
|
||||
10.0.. => Some(testing_color),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut accent_color = PixelWand::new();
|
||||
handle_magick_result!(
|
||||
accent_color.set_color("#ffffff"),
|
||||
"Failed to init accent color"
|
||||
);
|
||||
if let Some(color) = other_colors.first() {
|
||||
let col = format!("cielab{:?}", color.into_components());
|
||||
handle_magick_result!(
|
||||
accent_color.set_color(col.as_str()),
|
||||
"Failed to set accent color"
|
||||
);
|
||||
}
|
||||
handle_magick_result!(
|
||||
base_color.set_color("#7f7f7f"),
|
||||
"Failed to set init base color"
|
||||
);
|
||||
|
||||
let color_cache_entry = {
|
||||
let data = ctx.data.write().await;
|
||||
let color_cache = data.get::<ColorsHaver>().expect("Failed to have colors option");
|
||||
if color_cache.max > 0 {
|
||||
let color_cache_map = color_cache.map.lock().await;
|
||||
color_cache_map.get(image_uri.as_str()).map(|s| s.to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
let (base_lab, accent_lab) = match color_cache_entry {
|
||||
Some(value) => value.to_owned(),
|
||||
None => {
|
||||
let (art_width, art_height) = (art_wand.get_image_width(), art_wand.get_image_height());
|
||||
handle_magick_result!(
|
||||
art_wand_cluster.new_image(art_width, art_height, &base_color),
|
||||
"Failed to init cluster image"
|
||||
);
|
||||
handle_magick_result!(
|
||||
art_wand_cluster.compose_images(&art_wand, CompositeOperator::SrcOver, true, 0, 0),
|
||||
"Failed to copy onto cluster image"
|
||||
);
|
||||
// least stupid way I could figure out to do this, since it seems that MagickWand::new_from_wand() doesn't work
|
||||
handle_magick_result!(
|
||||
art_wand_cluster.set_image_colorspace(ColorspaceType::Lab),
|
||||
"Failed to set cluster colorspace"
|
||||
);
|
||||
unsafe { MagickSetSeed(0x5F3759DF); }
|
||||
handle_magick_result!(
|
||||
art_wand_cluster.kmeans(10, 200, 0.001),
|
||||
"Failed to run kmeans"
|
||||
);
|
||||
let mut colors = handle_magick_option!(
|
||||
art_wand_cluster.get_image_histogram(),
|
||||
"Failed to get color histogram"
|
||||
);
|
||||
colors.sort_by_cached_key(|color| -(color.get_color_count() as isize));
|
||||
let base_lab = validate_color(&colors[0]).expect("Failed to validate base color");
|
||||
let other_colors = colors
|
||||
.iter()
|
||||
.filter_map(|col| {
|
||||
let testing_color = validate_color(col)?;
|
||||
match testing_color.improved_difference(base_lab) {
|
||||
10.0.. => Some(testing_color),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut accent_lab = "cielab(0.0,0.0,0.0)".to_owned();
|
||||
if let Some(color) = other_colors.first() {
|
||||
accent_lab = format!("cielab{:?}", color.into_components());
|
||||
}
|
||||
let base_lab = handle_magick_result!(colors[0].get_color_as_string(), "Failed to serialize base color");
|
||||
let data = ctx.data.write().await;
|
||||
let color_cache = data.get::<ColorsHaver>().expect("Failed to have colors option");
|
||||
let mut color_cache_map = color_cache.map.lock().await;
|
||||
if color_cache_map.keys().len() > color_cache.max {
|
||||
let key: String = color_cache_map.iter().next().unwrap().0.to_owned();
|
||||
color_cache_map.remove(&key);
|
||||
}
|
||||
color_cache_map.insert(image_uri, (base_lab.clone(), accent_lab.clone()));
|
||||
(base_lab, accent_lab)
|
||||
}
|
||||
};
|
||||
|
||||
handle_magick_result!(base_color.set_color(base_lab.as_str()), "Failed to deserialize base color");
|
||||
handle_magick_result!(accent_color.set_color(accent_lab.as_str()), "Failed to deserialize accent color");
|
||||
|
||||
let use_dark_color = match validate_color(&base_color) {
|
||||
Some(color) => {
|
||||
let black_delta_e = Lab::<D65>::new(0.0, 0.0, 0.0).improved_difference(color);
|
||||
let white_delta_e = Lab::<D65>::new(100.0, 0.0, 0.0).improved_difference(color);
|
||||
black_delta_e > white_delta_e
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
handle_magick_result!(white.set_color("#ffffff"), "Failed to init white");
|
||||
handle_magick_result!(
|
||||
@@ -242,7 +414,7 @@ async fn fmi(ctx: &Context, arg: &str, id: UserId, avatar: Option<String>) -> Re
|
||||
"Failed to mask avatar"
|
||||
);
|
||||
handle_magick_result!(
|
||||
avatar_wand.adaptive_resize_image(64, 64),
|
||||
avatar_wand.resize_image(64, 64, FilterType::RobidouxSharp),
|
||||
"Failed to resize avatar"
|
||||
);
|
||||
handle_magick_result!(
|
||||
@@ -250,8 +422,31 @@ async fn fmi(ctx: &Context, arg: &str, id: UserId, avatar: Option<String>) -> Re
|
||||
"Failed to set avatar colorspace"
|
||||
);
|
||||
|
||||
let (image_width, image_height) = (548, 147);
|
||||
let data = ctx.data.write().await;
|
||||
let fonts = data.get::<FontsHaver>().expect("Failed to load fonts");
|
||||
let text_image_blob = match text::fmi_text(
|
||||
image_width,
|
||||
image_height,
|
||||
track_info,
|
||||
fonts,
|
||||
19.0,
|
||||
use_dark_color,
|
||||
) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => return Reply::Text(e),
|
||||
};
|
||||
handle_magick_result!(
|
||||
main_wand.new_image(548, 147, &colors[0]),
|
||||
text_image_wand.read_image_blob(&text_image_blob),
|
||||
"Failed to load text image"
|
||||
);
|
||||
handle_magick_result!(
|
||||
text_image_wand.transform_image_colorspace(ColorspaceType::Lab),
|
||||
"Failed to set avatar colorspace"
|
||||
);
|
||||
|
||||
handle_magick_result!(
|
||||
main_wand.new_image(image_width as usize, image_height as usize, &base_color),
|
||||
"Failed to create wand"
|
||||
);
|
||||
handle_magick_result!(
|
||||
@@ -259,13 +454,13 @@ async fn fmi(ctx: &Context, arg: &str, id: UserId, avatar: Option<String>) -> Re
|
||||
"Failed to set main colorspace"
|
||||
);
|
||||
handle_magick_result!(
|
||||
art_wand.adaptive_resize_image(124, 124),
|
||||
art_wand.resize_image(124, 124, FilterType::RobidouxSharp),
|
||||
"Failed to resize art"
|
||||
);
|
||||
handle_magick_result!(
|
||||
/*handle_magick_result!(
|
||||
art_wand_cluster.adaptive_resize_image(124, 124),
|
||||
"Failed to resize art_cluster"
|
||||
);
|
||||
);*/
|
||||
handle_magick_result!(
|
||||
main_wand.compose_images(&art_wand, CompositeOperator::SrcOver, false, 12, 12),
|
||||
"Failed to combine art image"
|
||||
@@ -274,15 +469,22 @@ async fn fmi(ctx: &Context, arg: &str, id: UserId, avatar: Option<String>) -> Re
|
||||
main_wand.compose_images(&mask_wand, CompositeOperator::SrcOver, false, 401, 0),
|
||||
"Failed to combine mask"
|
||||
);
|
||||
#[cfg(debug_assertions)]
|
||||
if env::var("DEBUG_IMAGE").unwrap_or("0".to_owned()) == "1" {
|
||||
handle_magick_result!(
|
||||
main_wand.compose_images(
|
||||
&art_wand_cluster,
|
||||
CompositeOperator::SrcOver,
|
||||
false,
|
||||
160,
|
||||
12
|
||||
),
|
||||
"Failed to combine debug image"
|
||||
);
|
||||
}
|
||||
handle_magick_result!(
|
||||
main_wand.compose_images(
|
||||
&art_wand_cluster,
|
||||
CompositeOperator::SrcOver,
|
||||
false,
|
||||
160,
|
||||
12
|
||||
),
|
||||
"Failed to combine debug image"
|
||||
main_wand.compose_images(&text_image_wand, CompositeOperator::SrcOver, false, 0, 0),
|
||||
"Failed to combine mask"
|
||||
);
|
||||
handle_magick_result!(
|
||||
main_wand.compose_images(&avatar_wand, CompositeOperator::SrcOver, false, 473, 73),
|
||||
@@ -291,25 +493,137 @@ async fn fmi(ctx: &Context, arg: &str, id: UserId, avatar: Option<String>) -> Re
|
||||
Reply::Image(main_wand.write_image_blob("png").unwrap())
|
||||
}
|
||||
|
||||
async fn text(ctx: &Context, arg: Option<&str>, id: UserId) -> Reply {
|
||||
let track = match get_track(ctx, arg, id).await {
|
||||
Ok(track) => track,
|
||||
Err(e) => return e,
|
||||
};
|
||||
Reply::Text(format!(
|
||||
"{}\n{}\n{}",
|
||||
track.name,
|
||||
track.artist.name,
|
||||
track.album,
|
||||
))
|
||||
}
|
||||
|
||||
fn help(arg: Option<&str>) -> Reply {
|
||||
Reply::Text(format!(
|
||||
"```{}```",
|
||||
match arg {
|
||||
None =>
|
||||
"usage: .k <img|set|art|url|spotify|help> [lastfm user]\n .k img [lastfm user]: get now playing\n .k set <lastfm user>: sets your username for the future\n .k art [lastfm user]: get now playing album art\n .k url [lastfm user]: get now playing lastfm url\n .k spot [lastfm user]: get now playing spotify link\n .k search <search query>: searches spotify\n .k help [command]: displays a help message"
|
||||
.to_owned(),
|
||||
Some("img" | "fmi" | "fm" | "get") =>
|
||||
"usage: .k img [lastfm user]\nreturns a pretty-printed image of the user's now playing track on last.fm, using the album art's colors to theme the image. see also: .k set\naliases: .kfmi, .k <fmi|get|img>, .kf, .ki, or even just .k"
|
||||
.to_owned(),
|
||||
Some("set") =>
|
||||
"usage: .k set <lastfm user>\nties a lastfm username to your account. use this to not require your username for .k fmi every time, for example\naliases: .kset"
|
||||
.to_owned(),
|
||||
Some("art") =>
|
||||
"usage: .k art [lastfm user]\nfetches user's now playing track's album art\naliases: .k art, .kart, .ka"
|
||||
.to_owned(),
|
||||
Some("url" | "link") =>
|
||||
"usage: .k url [lastfm user]\nfetches a lastfm link to user's now playing track\naliases: .k url, .k link, .kl, .ku, .kurl"
|
||||
.to_owned(),
|
||||
Some("spot" | "spotify") =>
|
||||
"usage: .k spotify [lastfm user]\nsearches spotify for the user's now playing track and returns a link to that track\naliases: .k <spot|spotify>, .kx, .kspot"
|
||||
.to_owned(),
|
||||
Some("search") =>
|
||||
"usage: .k search <search query>\nsearches spotify with an arbitrary query and returns the first result\naliases: .k search, .ks".to_owned(),
|
||||
Some(cmd) => format!("unknown command: {cmd}"),
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
async fn set(ctx: &Context, arg: &str, id: UserId) -> Reply {
|
||||
set_lastfm_username(ctx, id, arg.to_string()).await;
|
||||
Reply::Text(format!("set user {}", arg))
|
||||
set_lastfm_username(ctx, id, arg.to_owned()).await;
|
||||
Reply::Text(format!("set user {arg}"))
|
||||
}
|
||||
|
||||
async fn search(ctx: &Context, arg: &str) -> Reply {
|
||||
let mut data = ctx.data.write().await;
|
||||
let spotify_option = data.get_mut::<SpotifyHaver>().expect("Failed to have spotify option");
|
||||
if spotify_option.is_none() {
|
||||
return Reply::Text("Unable to use Spotify commands: Contact bot Administrator.".to_owned())
|
||||
}
|
||||
let spotify_arc_mutex = spotify_option.as_mut().unwrap();
|
||||
let spotify_client = spotify_arc_mutex.lock().await;
|
||||
spotify_client.request_token().await.unwrap();
|
||||
let search = spotify_client.search(arg, SearchType::Track, None, None, None, None).await;
|
||||
let tracks = match search {
|
||||
Ok(SearchResult::Tracks(track_page)) => track_page,
|
||||
Err(e) => return Reply::Text(format!("Failed to get track {e}")),
|
||||
_ => return Reply::Text("Spotify search failed".to_owned()),
|
||||
};
|
||||
match &tracks.items.first().map(|x| x.external_urls.get("spotify")) {
|
||||
Some(Some(url)) => Reply::Text(url.to_owned().to_owned()),
|
||||
_ => Reply::Text("Unable to get spotify url".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! cmd_pattern {
|
||||
($long_cmd:pat, $short_pattern:pat, $arg_name:pat) => {
|
||||
(Some(".k"), Some($long_cmd), $arg_name) | (Some(".k"), $arg_name, Some($long_cmd)) | (Some($short_pattern), $arg_name, None)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for Handler {
|
||||
async fn message(&self, ctx: Context, msg: Message) {
|
||||
let mut cmd_iter = msg.content.split(" ");
|
||||
let cmd = cmd_iter.next().unwrap_or("");
|
||||
let arg = cmd_iter.next().unwrap_or("");
|
||||
let resp = match cmd {
|
||||
".fmi" => {
|
||||
let cmd = cmd_iter.next();
|
||||
let arg1 = cmd_iter.next();
|
||||
let arg2 = cmd_iter.next();
|
||||
let resp = match (cmd, arg1, arg2) {
|
||||
(Some(".k"), Some("help" | "h" | "?"), arg) // skip the ".k <command> help" syntax
|
||||
| (Some(".kh") | Some(".k?"), arg, None) => {
|
||||
log!("{} received", msg.content);
|
||||
Some(fmi(&ctx, arg, msg.author.id, msg.author.avatar_url()).await)
|
||||
Some(help(arg))
|
||||
}
|
||||
".set" => {
|
||||
cmd_pattern!("set", ".kset" | ".set", Some(arg)) =>
|
||||
/*(".set", arg, "") | (".k", "set", arg) | (".kset", arg, "") =>*/ {
|
||||
log!("{} received", msg.content);
|
||||
Some(set(&ctx, arg, msg.author.id).await)
|
||||
},
|
||||
cmd_pattern!("art", ".ka" | ".kart", arg) =>
|
||||
/*(".k", "art", arg) | (".k", arg, "art") | (".ka" | ".kart", arg, "") =>*/ {
|
||||
log!("{} received", msg.content);
|
||||
Some(art(&ctx, arg, msg.author.id).await)
|
||||
},
|
||||
cmd_pattern!("url" | "uri" | "link", ".ku" | ".kl", arg) =>
|
||||
/*(".k", "url" | "uri" | "link", arg)
|
||||
| (".k", arg, "url" | "uri" | "link")
|
||||
| (".ku" | ".kl" | ".kurl", arg, "") =>*/ {
|
||||
log!("{} received", msg.content);
|
||||
Some(url(&ctx, arg, msg.author.id).await)
|
||||
},
|
||||
cmd_pattern!("text", ".kt", arg) =>
|
||||
/*(".k", "url" | "uri" | "link", arg)
|
||||
| (".k", arg, "url" | "uri" | "link")
|
||||
| (".ku" | ".kl" | ".kurl", arg, "") =>*/ {
|
||||
log!("{} received", msg.content);
|
||||
Some(text(&ctx, arg, msg.author.id).await)
|
||||
},
|
||||
cmd_pattern!("spot" | "spotify", ".kx", arg) =>
|
||||
/*(".k", "spot" | "spotify", arg)
|
||||
| (".k", arg, "spot" | "spotify")
|
||||
| (".ks" | ".kspot", arg, "") =>*/ {
|
||||
log!("{} received", msg.content);
|
||||
Some(spot(&ctx, arg, msg.author.id).await)
|
||||
},
|
||||
(Some(".ks"), Some(_), _) => {
|
||||
log!("{} received", msg.content);
|
||||
Some(search(&ctx, &msg.content[4..]).await)
|
||||
},
|
||||
(Some(".k"), Some("search"), Some(_)) => {
|
||||
log!("{} received", msg.content);
|
||||
Some(search(&ctx, &msg.content[10..]).await)
|
||||
},
|
||||
cmd_pattern!("fm" | "fmi" | "get" | "img", ".fmi" | ".k" | ".kf" | ".ki" | ".kfmi", arg) =>
|
||||
/*(".fmi" | ".k" | ".kf" | ".ki" | ".kfmi", arg, "")
|
||||
| (".k", "fm" | "fmi" | "get" | "img", arg) =>*/ {
|
||||
log!("{} received", msg.content);
|
||||
Some(fmi(&ctx, arg, msg.author.id, msg.author.avatar_url()).await)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
@@ -330,16 +644,44 @@ impl EventHandler for Handler {
|
||||
}
|
||||
}
|
||||
|
||||
struct HttpContainer;
|
||||
impl TypeMapKey for HttpContainer {
|
||||
struct HttpHaver;
|
||||
impl TypeMapKey for HttpHaver {
|
||||
type Value = reqwest_middleware::ClientWithMiddleware;
|
||||
}
|
||||
|
||||
struct PoolContainer;
|
||||
impl TypeMapKey for PoolContainer {
|
||||
struct PoolHaver;
|
||||
impl TypeMapKey for PoolHaver {
|
||||
type Value = SqlitePool;
|
||||
}
|
||||
|
||||
struct FontsHaver;
|
||||
impl TypeMapKey for FontsHaver {
|
||||
type Value = (SuperFont<'static>, SuperFont<'static>);
|
||||
}
|
||||
|
||||
struct SpotifyHaver;
|
||||
impl TypeMapKey for SpotifyHaver {
|
||||
type Value = Option<Arc<Mutex<ClientCredsSpotify>>>;
|
||||
}
|
||||
|
||||
struct ColorCache {
|
||||
map: Arc<Mutex<HashMap<String, (String, String)>>>,
|
||||
max: usize,
|
||||
}
|
||||
struct ColorsHaver;
|
||||
impl TypeMapKey for ColorsHaver {
|
||||
type Value = ColorCache;
|
||||
}
|
||||
|
||||
struct Overrides {
|
||||
map: Arc<Mutex<HashMap<String, String>>>,
|
||||
last_modified: Option<SystemTime>,
|
||||
}
|
||||
struct OverridesHaver;
|
||||
impl TypeMapKey for OverridesHaver {
|
||||
type Value = Overrides;
|
||||
}
|
||||
|
||||
struct DBResponse {
|
||||
lastfm_username: String,
|
||||
}
|
||||
@@ -348,7 +690,7 @@ async fn get_lastfm_username(ctx: &Context, id: UserId) -> Option<String> {
|
||||
log!("get db user {}", id);
|
||||
let data = ctx.data.write().await;
|
||||
let pool = data
|
||||
.get::<PoolContainer>()
|
||||
.get::<PoolHaver>()
|
||||
.expect("Failed to get pool container");
|
||||
let id = i64::from(id);
|
||||
let resp = sqlx::query_as!(
|
||||
@@ -373,9 +715,9 @@ async fn get_lastfm_username(ctx: &Context, id: UserId) -> Option<String> {
|
||||
|
||||
async fn set_lastfm_username(ctx: &Context, id: UserId, user: String) {
|
||||
log!("set db user {} {}", id, user);
|
||||
let data = ctx.data.write().await;
|
||||
let data = ctx.data.read().await;
|
||||
let pool = data
|
||||
.get::<PoolContainer>()
|
||||
.get::<PoolHaver>()
|
||||
.expect("Failed to get pool container");
|
||||
let id = i64::from(id);
|
||||
sqlx::query!(
|
||||
@@ -392,11 +734,9 @@ async fn set_lastfm_username(ctx: &Context, id: UserId, user: String) {
|
||||
}
|
||||
|
||||
async fn get_image(ctx: &Context, url: &str) -> Result<Vec<u8>, reqwest_middleware::Error> {
|
||||
log!("get {}", url);
|
||||
let data = ctx.data.write().await;
|
||||
let http = data
|
||||
.get::<HttpContainer>()
|
||||
.expect("Failed to get http client");
|
||||
log!("get {}", url);
|
||||
let data = ctx.data.read().await;
|
||||
let http = data.get::<HttpHaver>().expect("Failed to get http client");
|
||||
match http.get(url).send().await {
|
||||
Ok(resp) => {
|
||||
log!("response received");
|
||||
@@ -428,10 +768,6 @@ async fn main() {
|
||||
| GatewayIntents::DIRECT_MESSAGES
|
||||
| GatewayIntents::MESSAGE_CONTENT;
|
||||
|
||||
let mut discord_client = Client::builder(&token, intents)
|
||||
.event_handler(Handler)
|
||||
.await
|
||||
.expect("Err creating Discord client");
|
||||
let http = reqwest_middleware::ClientBuilder::new(reqwest::Client::new())
|
||||
.with(Cache(HttpCache {
|
||||
mode: CacheMode::Default,
|
||||
@@ -439,10 +775,69 @@ async fn main() {
|
||||
options: HttpCacheOptions::default(),
|
||||
}))
|
||||
.build();
|
||||
|
||||
FontDB::load_from_dir("fonts"); //singlet instance??? wha?????
|
||||
let regular_fonts = FontDB::query(
|
||||
"
|
||||
NotoSans-Regular
|
||||
NotoSansHK-Regular
|
||||
NotoSansJP-Regular
|
||||
NotoSansKR-Regular
|
||||
NotoSansSC-Regular
|
||||
NotoSansTC-Regular
|
||||
NotoSansArabic-Regular
|
||||
Heebo-Regular
|
||||
NotoEmoji-Regular
|
||||
Symbola
|
||||
Unifont
|
||||
",
|
||||
)
|
||||
.expect("Failed to load regular fonts");
|
||||
let bold_fonts = FontDB::query(
|
||||
"
|
||||
NotoSans-SemiBold
|
||||
NotoSansJP-Medium
|
||||
NotoSansKR-Medium
|
||||
NotoSansSC-Medium
|
||||
NotoSansTC-Medium
|
||||
NotoSansArabic-SemiBold
|
||||
Heebo-SemiBold
|
||||
NotoEmoji-Medium
|
||||
Symbola
|
||||
Unifont
|
||||
",
|
||||
)
|
||||
.expect("Failed to load bold fonts");
|
||||
|
||||
let spotify_creds = Credentials::from_env();
|
||||
let spotify = spotify_creds.map(ClientCredsSpotify::new);
|
||||
if let Some(spotify_client) = &spotify {
|
||||
let _ = &spotify_client.request_token().await.unwrap();
|
||||
}
|
||||
|
||||
let colors_cache = ColorCache {
|
||||
map: Arc::new(Mutex::new(HashMap::new())),
|
||||
max: env::var("MAX_CACHE_SIZE").unwrap_or("".to_owned()).parse::<usize>().unwrap_or(1000000),
|
||||
};
|
||||
|
||||
let overrides = Overrides {
|
||||
map: Arc::new(Mutex::new(HashMap::new())),
|
||||
last_modified: None,
|
||||
};
|
||||
|
||||
let mut discord_client = Client::builder(&token, intents)
|
||||
.event_handler(Handler)
|
||||
.await
|
||||
.expect("Err creating Discord client");
|
||||
|
||||
{
|
||||
let mut data = discord_client.data.write().await;
|
||||
data.insert::<PoolContainer>(pool);
|
||||
data.insert::<HttpContainer>(http);
|
||||
data.insert::<PoolHaver>(pool);
|
||||
data.insert::<HttpHaver>(http);
|
||||
data.insert::<FontsHaver>((regular_fonts, bold_fonts));
|
||||
data.insert::<SpotifyHaver>(spotify.map(Mutex::new).map(Arc::new));
|
||||
data.insert::<ColorsHaver>(colors_cache);
|
||||
data.insert::<OverridesHaver>(overrides);
|
||||
}
|
||||
|
||||
if let Err(why) = discord_client.start().await {
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
// this code is mostly just blindly ported from
|
||||
// https://github.com/adamanldo/Cosmo/blob/946af25dcac8a81cb7b49d1a4f83731a5f7c7b46/cogs/utils/fmi_text.py
|
||||
// since I'm using the same text rendering library, my code is almost one-to-one
|
||||
|
||||
use arabic_reshaper::arabic_reshape;
|
||||
use image::codecs::png::PngEncoder;
|
||||
use image::{ExtendedColorType, ImageEncoder, RgbaImage};
|
||||
use imagetext::measure::text_width;
|
||||
use imagetext::prelude::{BLACK, Outline, TextAlign, WHITE, draw_text_wrapped, scale, stroke};
|
||||
use imagetext::superfont::SuperFont;
|
||||
use imagetext::wrap::{WrapStyle, text_wrap};
|
||||
use unicode_bidi::BidiInfo;
|
||||
|
||||
enum TextField {
|
||||
Title,
|
||||
Artist,
|
||||
Album,
|
||||
}
|
||||
|
||||
fn process_text(
|
||||
text: &str,
|
||||
field: &TextField,
|
||||
font: &SuperFont<'static>,
|
||||
font_size: f32,
|
||||
) -> (Vec<String>, i32) {
|
||||
let mut text = text.to_string();
|
||||
if text
|
||||
.chars()
|
||||
.any(|x| matches!(x as u32, 0x590..=0x5fe | 0x600..=0x6ff0))
|
||||
{
|
||||
if text.chars().any(|x| matches!(x as u32, 0x600..=0x6ff0)) {
|
||||
text = arabic_reshape(text.as_str());
|
||||
}
|
||||
let bidi = BidiInfo::new(text.as_str(), None);
|
||||
text = bidi
|
||||
.paragraphs
|
||||
.iter()
|
||||
.map(|para| {
|
||||
let line = para.range.clone();
|
||||
bidi.reorder_line(para, line)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let (width, allowed_lines) = match field {
|
||||
TextField::Title => (350, 2),
|
||||
TextField::Artist => (312, 1),
|
||||
TextField::Album => (280, 2),
|
||||
};
|
||||
let mut wrapped_strs = text_wrap(
|
||||
text.as_str(),
|
||||
width,
|
||||
font,
|
||||
scale(font_size),
|
||||
WrapStyle::Character,
|
||||
text_width,
|
||||
);
|
||||
if wrapped_strs.len() > allowed_lines {
|
||||
wrapped_strs.truncate(allowed_lines);
|
||||
let text_len = wrapped_strs[allowed_lines - 1].len() - 3;
|
||||
wrapped_strs[allowed_lines - 1] = match allowed_lines {
|
||||
1..=2 => format!(
|
||||
"{}...",
|
||||
wrapped_strs[allowed_lines - 1].get(..text_len).unwrap()
|
||||
),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
}
|
||||
(wrapped_strs, width)
|
||||
}
|
||||
|
||||
fn draw_info(
|
||||
image: &mut RgbaImage,
|
||||
text: String,
|
||||
text_type: TextField,
|
||||
font: &SuperFont<'static>,
|
||||
font_size: f32,
|
||||
dark_text: bool,
|
||||
) -> Result<(), String> {
|
||||
let (lines, width) = process_text(&text, &text_type, font, font_size);
|
||||
let text = lines.join("");
|
||||
let y = match text_type {
|
||||
TextField::Title => 24.0,
|
||||
TextField::Artist => 73.0,
|
||||
TextField::Album => 96.0,
|
||||
};
|
||||
let color = if dark_text { &BLACK } else { &WHITE };
|
||||
match draw_text_wrapped(
|
||||
image,
|
||||
color,
|
||||
Outline::Solid {
|
||||
stroke: &stroke(0.5),
|
||||
fill: color,
|
||||
},
|
||||
146.0,
|
||||
y,
|
||||
0.0,
|
||||
0.0,
|
||||
width as f32,
|
||||
scale(font_size),
|
||||
font,
|
||||
&text,
|
||||
1.0,
|
||||
TextAlign::Left,
|
||||
WrapStyle::Character,
|
||||
) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TrackInfo {
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub album: String,
|
||||
}
|
||||
|
||||
pub fn fmi_text(
|
||||
width: u32,
|
||||
height: u32,
|
||||
track: TrackInfo,
|
||||
fonts: &(SuperFont<'static>, SuperFont<'static>),
|
||||
font_size: f32,
|
||||
dark_text: bool,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let mut img = RgbaImage::new(width, height);
|
||||
draw_info(
|
||||
&mut img,
|
||||
track.title,
|
||||
TextField::Title,
|
||||
&fonts.1.clone(),
|
||||
font_size,
|
||||
dark_text,
|
||||
)?;
|
||||
draw_info(
|
||||
&mut img,
|
||||
track.artist,
|
||||
TextField::Artist,
|
||||
&fonts.0.clone(),
|
||||
font_size,
|
||||
dark_text,
|
||||
)?;
|
||||
draw_info(
|
||||
&mut img,
|
||||
track.album,
|
||||
TextField::Album,
|
||||
&fonts.0.clone(),
|
||||
font_size,
|
||||
dark_text,
|
||||
)?;
|
||||
let mut blob = Vec::<u8>::new();
|
||||
let png_encoder = PngEncoder::new(&mut blob);
|
||||
match png_encoder.write_image(&img, width, height, ExtendedColorType::Rgba8) {
|
||||
Ok(()) => Ok(blob),
|
||||
Err(_) => Err("Failed to encode text png".to_string()),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user