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"
http-cache-reqwest = "0.15.1"
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]
path = "./magick-rust"
+3 -1
View File
@@ -28,4 +28,6 @@ init the database ``sqlx migrate run``
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::*;
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::sync::Once;
@@ -23,6 +23,11 @@ use palette::white_point::D65;
extern crate sqlx;
use sqlx::sqlite::SqlitePool;
use imagetext::fontdb::FontDB;
use imagetext::superfont::SuperFont;
mod text;
static START: Once = Once::new();
struct Handler;
@@ -126,6 +131,11 @@ async fn fmi(ctx: &Context, arg: &str, id: UserId, avatar: Option<String>) -> Re
Some(track) => track,
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 {
Some(iu) => iu,
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 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"),
@@ -191,7 +202,7 @@ async fn fmi(ctx: &Context, arg: &str, id: UserId, avatar: Option<String>) -> Re
.collect::<Vec<_>>();
let mut accent_color = PixelWand::new();
handle_magick_result!(
accent_color.set_color("#ffffff"),
accent_color.set_color("cielab(0.0,0.0,0.0)"),
"Failed to init accent color"
);
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!(
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"
);
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 +271,22 @@ 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!(text_image_wand.read_image_blob(&text_image_blob), "Failed to load text image");
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"
);
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),
"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!(
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),
@@ -330,16 +372,21 @@ 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 DBResponse {
lastfm_username: String,
}
@@ -348,7 +395,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!(
@@ -375,7 +422,7 @@ async fn set_lastfm_username(ctx: &Context, id: UserId, user: String) {
log!("set db user {} {}", id, user);
let data = ctx.data.write().await;
let pool = data
.get::<PoolContainer>()
.get::<PoolHaver>()
.expect("Failed to get pool container");
let id = i64::from(id);
sqlx::query!(
@@ -395,7 +442,7 @@ async fn get_image(ctx: &Context, url: &str) -> Result<Vec<u8>, reqwest_middlewa
log!("get {}", url);
let data = ctx.data.write().await;
let http = data
.get::<HttpContainer>()
.get::<HttpHaver>()
.expect("Failed to get http client");
match http.get(url).send().await {
Ok(resp) => {
@@ -428,10 +475,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 +482,44 @@ 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 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));
}
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()),
}
}