Added text rendering, fixed bugs

This commit is contained in:
2025-05-17 06:22:20 -05:00
parent 270878e4fa
commit b26b94dfa6
26 changed files with 939 additions and 26 deletions
Generated
+708
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -11,6 +11,11 @@ reqwest = "0.12"
reqwest-middleware = "0.4.2" reqwest-middleware = "0.4.2"
http-cache-reqwest = "0.15.1" http-cache-reqwest = "0.15.1"
palette = "0.7.6" palette = "0.7.6"
# 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] [dependencies.magick_rust]
path = "./magick-rust" path = "./magick-rust"
+3 -1
View File
@@ -28,4 +28,6 @@ init the database ``sqlx migrate run``
TODO TODO
---- ----
literally just add the text + ~~literally just add the text~~
+ add easter egg
+ make various parameters user-configurable
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.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

+102 -25
View File
@@ -11,7 +11,7 @@ use serenity::model::id::UserId;
use serenity::prelude::*; use serenity::prelude::*;
extern crate magick_rust; extern crate magick_rust;
use magick_rust::{ColorspaceType, CompositeOperator, MagickWand, PixelWand, magick_wand_genesis}; use magick_rust::{ColorspaceType, CompositeOperator, MagickWand, PixelWand, magick_wand_genesis, FilterType};
use std::env; use std::env;
use std::sync::Once; use std::sync::Once;
@@ -23,6 +23,11 @@ use palette::white_point::D65;
extern crate sqlx; extern crate sqlx;
use sqlx::sqlite::SqlitePool; use sqlx::sqlite::SqlitePool;
use imagetext::fontdb::FontDB;
use imagetext::superfont::SuperFont;
mod text;
static START: Once = Once::new(); static START: Once = Once::new();
struct Handler; struct Handler;
@@ -126,6 +131,11 @@ async fn fmi(ctx: &Context, arg: &str, id: UserId, avatar: Option<String>) -> Re
Some(track) => track, Some(track) => track,
None => return Reply::Text("Nothing playing.".to_string()), None => return Reply::Text("Nothing playing.".to_string()),
}; };
let track_info = text::TrackInfo {
title: track.name,
artist: track.artist.name,
album: track.album,
};
let image_uri = match track.image.extralarge { let image_uri = match track.image.extralarge {
Some(iu) => iu, Some(iu) => iu,
None => return Reply::Text("Error: getting image uri failed".to_string()), None => return Reply::Text("Error: getting image uri failed".to_string()),
@@ -141,6 +151,7 @@ async fn fmi(ctx: &Context, arg: &str, id: UserId, avatar: Option<String>) -> Re
let art_wand = MagickWand::new(); let art_wand = MagickWand::new();
let mut art_wand_cluster = MagickWand::new(); let mut art_wand_cluster = MagickWand::new();
let mask_wand = MagickWand::new(); let mask_wand = MagickWand::new();
let text_image_wand = MagickWand::new();
handle_magick_result!( handle_magick_result!(
base_color.set_color("#7f7f7f"), base_color.set_color("#7f7f7f"),
@@ -191,7 +202,7 @@ async fn fmi(ctx: &Context, arg: &str, id: UserId, avatar: Option<String>) -> Re
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut accent_color = PixelWand::new(); let mut accent_color = PixelWand::new();
handle_magick_result!( handle_magick_result!(
accent_color.set_color("#ffffff"), accent_color.set_color("cielab(0.0,0.0,0.0)"),
"Failed to init accent color" "Failed to init accent color"
); );
if let Some(color) = other_colors.first() { if let Some(color) = other_colors.first() {
@@ -202,6 +213,16 @@ async fn fmi(ctx: &Context, arg: &str, id: UserId, avatar: Option<String>) -> Re
); );
} }
let use_dark_color = match validate_color(&colors[0]) {
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!(white.set_color("#ffffff"), "Failed to init white");
handle_magick_result!( handle_magick_result!(
mask_wand.read_image_blob(include_bytes!("accent_mask.png")), mask_wand.read_image_blob(include_bytes!("accent_mask.png")),
@@ -242,7 +263,7 @@ async fn fmi(ctx: &Context, arg: &str, id: UserId, avatar: Option<String>) -> Re
"Failed to mask avatar" "Failed to mask avatar"
); );
handle_magick_result!( handle_magick_result!(
avatar_wand.adaptive_resize_image(64, 64), avatar_wand.resize_image(64, 64, FilterType::RobidouxSharp),
"Failed to resize avatar" "Failed to resize avatar"
); );
handle_magick_result!( handle_magick_result!(
@@ -250,8 +271,22 @@ async fn fmi(ctx: &Context, arg: &str, id: UserId, avatar: Option<String>) -> Re
"Failed to set avatar colorspace" "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!(text_image_wand.read_image_blob(&text_image_blob), "Failed to load text image");
handle_magick_result!( handle_magick_result!(
main_wand.new_image(548, 147, &colors[0]), 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, &colors[0]),
"Failed to create wand" "Failed to create wand"
); );
handle_magick_result!( handle_magick_result!(
@@ -274,15 +309,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), main_wand.compose_images(&mask_wand, CompositeOperator::SrcOver, false, 401, 0),
"Failed to combine mask" "Failed to combine mask"
); );
#[cfg(debug_assertions)]
if env::var("DEBUG_IMAGE").unwrap_or("0".to_string()) == "1" {
handle_magick_result!(
main_wand.compose_images(
&art_wand_cluster,
CompositeOperator::SrcOver,
false,
160,
12
),
"Failed to combine debug image"
);
}
handle_magick_result!( handle_magick_result!(
main_wand.compose_images( main_wand.compose_images(&text_image_wand, CompositeOperator::SrcOver, false, 0, 0),
&art_wand_cluster, "Failed to combine mask"
CompositeOperator::SrcOver,
false,
160,
12
),
"Failed to combine debug image"
); );
handle_magick_result!( handle_magick_result!(
main_wand.compose_images(&avatar_wand, CompositeOperator::SrcOver, false, 473, 73), main_wand.compose_images(&avatar_wand, CompositeOperator::SrcOver, false, 473, 73),
@@ -330,16 +372,21 @@ impl EventHandler for Handler {
} }
} }
struct HttpContainer; struct HttpHaver;
impl TypeMapKey for HttpContainer { impl TypeMapKey for HttpHaver {
type Value = reqwest_middleware::ClientWithMiddleware; type Value = reqwest_middleware::ClientWithMiddleware;
} }
struct PoolContainer; struct PoolHaver;
impl TypeMapKey for PoolContainer { impl TypeMapKey for PoolHaver {
type Value = SqlitePool; type Value = SqlitePool;
} }
struct FontsHaver;
impl TypeMapKey for FontsHaver {
type Value = (SuperFont<'static>, SuperFont<'static>);
}
struct DBResponse { struct DBResponse {
lastfm_username: String, lastfm_username: String,
} }
@@ -348,7 +395,7 @@ async fn get_lastfm_username(ctx: &Context, id: UserId) -> Option<String> {
log!("get db user {}", id); log!("get db user {}", id);
let data = ctx.data.write().await; let data = ctx.data.write().await;
let pool = data let pool = data
.get::<PoolContainer>() .get::<PoolHaver>()
.expect("Failed to get pool container"); .expect("Failed to get pool container");
let id = i64::from(id); let id = i64::from(id);
let resp = sqlx::query_as!( let resp = sqlx::query_as!(
@@ -375,7 +422,7 @@ async fn set_lastfm_username(ctx: &Context, id: UserId, user: String) {
log!("set db user {} {}", id, user); log!("set db user {} {}", id, user);
let data = ctx.data.write().await; let data = ctx.data.write().await;
let pool = data let pool = data
.get::<PoolContainer>() .get::<PoolHaver>()
.expect("Failed to get pool container"); .expect("Failed to get pool container");
let id = i64::from(id); let id = i64::from(id);
sqlx::query!( sqlx::query!(
@@ -395,7 +442,7 @@ async fn get_image(ctx: &Context, url: &str) -> Result<Vec<u8>, reqwest_middlewa
log!("get {}", url); log!("get {}", url);
let data = ctx.data.write().await; let data = ctx.data.write().await;
let http = data let http = data
.get::<HttpContainer>() .get::<HttpHaver>()
.expect("Failed to get http client"); .expect("Failed to get http client");
match http.get(url).send().await { match http.get(url).send().await {
Ok(resp) => { Ok(resp) => {
@@ -428,10 +475,6 @@ async fn main() {
| GatewayIntents::DIRECT_MESSAGES | GatewayIntents::DIRECT_MESSAGES
| GatewayIntents::MESSAGE_CONTENT; | 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()) let http = reqwest_middleware::ClientBuilder::new(reqwest::Client::new())
.with(Cache(HttpCache { .with(Cache(HttpCache {
mode: CacheMode::Default, mode: CacheMode::Default,
@@ -439,10 +482,44 @@ async fn main() {
options: HttpCacheOptions::default(), options: HttpCacheOptions::default(),
})) }))
.build(); .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 mut discord_client = Client::builder(&token, intents)
.event_handler(Handler)
.await
.expect("Err creating Discord client");
{ {
let mut data = discord_client.data.write().await; let mut data = discord_client.data.write().await;
data.insert::<PoolContainer>(pool); data.insert::<PoolHaver>(pool);
data.insert::<HttpContainer>(http); data.insert::<HttpHaver>(http);
data.insert::<FontsHaver>((regular_fonts, bold_fonts));
} }
if let Err(why) = discord_client.start().await { if let Err(why) = discord_client.start().await {
+121
View File
@@ -0,0 +1,121 @@
// 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 imagetext::measure::text_width;
use imagetext::superfont::SuperFont;
use imagetext::wrap::{text_wrap, WrapStyle};
use imagetext::prelude::{scale, BLACK, WHITE, Outline, draw_text_wrapped, TextAlign, stroke};
use image::{RgbaImage, ExtendedColorType, ImageEncoder};
use image::codecs::png::PngEncoder;
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()),
}
}