Added text rendering, fixed bugs
This commit is contained in:
Generated
+708
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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.
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 |
+102
-25
@@ -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
@@ -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()),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user