Compare commits

...

6 Commits

Author SHA1 Message Date
d8e94cf574 ugh 2025-05-23 06:52:08 -05:00
6038240d5a Porting to tiny_skia: art and avatar working 2025-05-23 06:51:59 -05:00
4d71f7fe23 Starting process of porting to tiny skia 2025-05-23 06:51:40 -05:00
fd87302642 Offically specified v0.2.0 2025-05-21 21:29:56 -05:00
41fbb5dbdf Added LICENSE 2025-05-17 06:27:06 -05:00
b26b94dfa6 Added text rendering, fixed bugs 2025-05-17 06:22:44 -05:00
28 changed files with 1148 additions and 154 deletions

797
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package] [package]
name = "watcat" name = "watcat"
version = "0.1.0" version = "0.2.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
@ -11,9 +11,18 @@ 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"
tiny-skia = "0.11.4"
auto-palette = "0.8.2"
#rand = "0.9.1"
#rayon = "1.10.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] #[dependencies.magick_rust]
path = "./magick-rust" #path = "./magick-rust"
[dependencies.tokio] [dependencies.tokio]
version = "1.21.2" version = "1.21.2"

27
LICENSE Normal file
View File

@ -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.

View File

@ -8,7 +8,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 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 but your code sucks
------------------- -------------------
@ -28,4 +28,7 @@ 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
+ use tiny-skia instead of magick-rust?

BIN
fonts/Heebo-Regular.ttf Normal file

Binary file not shown.

BIN
fonts/Heebo-SemiBold.ttf Normal file

Binary file not shown.

BIN
fonts/NotoEmoji-Medium.ttf Normal file

Binary file not shown.

BIN
fonts/NotoEmoji-Regular.ttf Normal file

Binary file not shown.

BIN
fonts/NotoSans-Regular.ttf Normal file

Binary file not shown.

BIN
fonts/NotoSans-SemiBold.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
fonts/NotoSansHK-Medium.otf Normal file

Binary file not shown.

Binary file not shown.

BIN
fonts/NotoSansJP-Medium.otf Normal file

Binary file not shown.

Binary file not shown.

BIN
fonts/NotoSansKR-Medium.otf Normal file

Binary file not shown.

Binary file not shown.

BIN
fonts/NotoSansSC-Medium.otf Normal file

Binary file not shown.

Binary file not shown.

BIN
fonts/NotoSansTC-Medium.otf Normal file

Binary file not shown.

Binary file not shown.

BIN
fonts/Symbola.ttf Normal file

Binary file not shown.

BIN
fonts/Unifont.otf Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

93
src/img.rs Normal file
View File

@ -0,0 +1,93 @@
use image::{RgbaImage, ExtendedColorType, ImageEncoder, load_from_memory};
use image::codecs::png::PngEncoder;
use tiny_skia::{Pixmap, Transform, PixmapPaint, BlendMode, FilterQuality, Color, Paint, Shader};
use palette::{Srgb, Lab, FromColor};
use auto_palette::{ImageData, Palette};
/*use palette::color_difference::{EuclideanDistance, ImprovedDeltaE};
use palette::white_point::D65;
use rand::{SeedableRng};
use rand::prelude::{IteratorRandom};
use rand::rngs::{SmallRng};
use std::ops::{Add, Div};//{Add, Sub, Mul};*/
pub fn save_to_memory_png(p: &RgbaImage) -> Result<Vec<u8>, String> {
let (width, height) = p.dimensions();
let mut blob = Vec::<u8>::new();
let png_encoder = PngEncoder::new(&mut blob);
match png_encoder.write_image(p, width, height, ExtendedColorType::Rgba8) {
Ok(()) => Ok(blob),
Err(_) => Err("Failed to encode text png".to_string()),
}
}
pub fn ensure_32_bit_png(p: &[u8]) -> Result<Vec<u8>, String> {
let img = match load_from_memory(p) {
Ok(i) => i,
Err(e) => return Err(format!("Failed to load png: {e}")),
};
save_to_memory_png(&img.into_rgba8())
}
pub fn scale_skia_pixmap(p: Pixmap, width: u32, height: u32) -> Option<Pixmap> {
let mut new_pixmap = Pixmap::new(width, height)?;
let width_scale = width as f32 / p.width() as f32;
let height_scale = height as f32 / p.width() as f32;
let matrix = Transform::from_scale(width_scale, height_scale);
let pixmap_paint = PixmapPaint {
opacity: 1.0,
blend_mode: BlendMode::Source,
quality: FilterQuality::Bilinear,
};
new_pixmap.draw_pixmap(0, 0, p.as_ref(), &pixmap_paint, matrix, None);
Some(new_pixmap)
}
pub fn lab_to_skia_color(c: Lab) -> Result<Color, String> {
let rgb: Srgb<f32> = Srgb::from_color(c).into_format();
match Color::from_rgba(rgb.red, rgb.green, rgb.blue, 1.0) {
Some(c) => Ok(c),
None => Err(format!("Invalid color: {rgb:?}")),
}
}
pub fn color_paint_from_lab_color<'a>(c: Lab) -> Result<Paint<'a>, String> {
//let color = lab_to_skia_color(c)?;
Ok(Paint {
shader: Shader::SolidColor(lab_to_skia_color(c)?),
anti_alias: true,
blend_mode: BlendMode::Source,
force_hq_pipeline: false,
})
}
pub fn dominant_colors_as_paints<'a>(p: &Pixmap) -> Result<(Paint<'a>, Paint<'a>), String> {
let imagedata = match ImageData::new(p.width(), p.height(), p.data()) {
Ok(i) => i,
Err(_) => return Err("Failed to load imagedata".to_string()),
};
let pal: Palette<f32> = match Palette::extract(&imagedata) {
Ok(p) => p,
Err(_) => return Err("Failed to to create palette".to_string()),
};
let colors: Vec<_> = match pal.find_swatches(2) {
Ok(s) => s,
Err(_) => return Err("Failed to get swatches".to_string()),
};
let colors = colors.iter().map(|swatch| {
let rgb = swatch.color().to_rgb();
Srgb::new(rgb.r, rgb.g, rgb.b).into()
}).collect::<Vec<Srgb<f32>>>();
Ok((Paint {
shader: Shader::SolidColor(Color::from_rgba(colors[0].red, colors[0].green, colors[0].blue, 1.0).unwrap()),
anti_alias: true,
blend_mode: BlendMode::Source,
force_hq_pipeline: false,
},
Paint {
shader: Shader::SolidColor(Color::from_rgba(colors[1].red, colors[1].green, colors[1].blue, 1.0).unwrap()),
anti_alias: true,
blend_mode: BlendMode::Source,
force_hq_pipeline: false,
}))
}

View File

@ -1,3 +1,7 @@
//#![allow(dead_code)]
//#![allow(unused_variables)]
//#![allow(unused_imports)]
//#![allow(unused_macros)]
extern crate dotenvy; extern crate dotenvy;
extern crate lastfm; extern crate lastfm;
extern crate reqwest; extern crate reqwest;
@ -10,19 +14,28 @@ use serenity::model::channel::Message;
use serenity::model::id::UserId; use serenity::model::id::UserId;
use serenity::prelude::*; use serenity::prelude::*;
extern crate magick_rust; extern crate tiny_skia;
use magick_rust::{ColorspaceType, CompositeOperator, MagickWand, PixelWand, magick_wand_genesis}; extern crate image;
//extern crate magick_rust;
//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;
use palette::color_difference::ImprovedCiede2000; //use palette::color_difference::ImprovedCiede2000;
use palette::lab::Lab; //use palette::lab::Lab;
use palette::white_point::D65; //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;
mod img;
static START: Once = Once::new(); static START: Once = Once::new();
struct Handler; struct Handler;
@ -55,18 +68,19 @@ macro_rules! log {
}; };
} }
macro_rules! handle_magick_result { macro_rules! handle_result {
($a: expr, $b: literal) => { ($a: expr, $b: literal) => {
match $a { match $a {
Ok(_) => { Ok(a) => {
log!("{} run successfully", stringify!($a)); log!("{} run successfully", stringify!($a));
a
} }
Err(e) => return Reply::Text(format!("Error: {} {}", $b, e)), Err(e) => return Reply::Text(format!("Error: {} {}", $b, e)),
} }
}; };
} }
macro_rules! handle_magick_option { macro_rules! handle_option {
($a: expr, $b: literal) => { ($a: expr, $b: literal) => {
match $a { match $a {
Some(res) => res, Some(res) => res,
@ -75,6 +89,7 @@ macro_rules! handle_magick_option {
}; };
} }
/*
// this function is almost assuredly the least idiomatic rust code I will ever write. // this function is almost assuredly the least idiomatic rust code I will ever write.
// but until I know what the "correct" way is, it'll have to do // but until I know what the "correct" way is, it'll have to do
fn parse_cielab(s: String) -> Result<(f32, f32, f32), &'static str> { fn parse_cielab(s: String) -> Result<(f32, f32, f32), &'static str> {
@ -95,9 +110,9 @@ fn parse_cielab(s: String) -> Result<(f32, f32, f32), &'static str> {
&[a, b, c] => Ok((a, b, c)), &[a, b, c] => Ok((a, b, c)),
_ => Err("Error parsing cielab string"), _ => Err("Error parsing cielab string"),
} }
} }*/
fn validate_color(col: &PixelWand) -> Option<Lab> { /*fn validate_color(col: &PixelWand) -> Option<Lab> {
let color_str = match col.get_color_as_string() { let color_str = match col.get_color_as_string() {
Ok(s) => s, Ok(s) => s,
Err(_) => return None, Err(_) => return None,
@ -107,17 +122,20 @@ fn validate_color(col: &PixelWand) -> Option<Lab> {
Err(_) => return None, Err(_) => return None,
}; };
Some(Lab::<D65>::from_components(color_raw)) Some(Lab::<D65>::from_components(color_raw))
} }*/
const FMI_WIDTH: u32 = 548;
const FMI_HEIGHT: u32 = 147;
const FMI_GAP: u32 = 12;
const FMI_AVATAR_SIZE: u32 = 64;
const FMI_AVATAR_GAP: u32 = 10;
async fn fmi(ctx: &Context, arg: &str, id: UserId, avatar: Option<String>) -> Reply { async fn fmi(ctx: &Context, arg: &str, id: UserId, avatar: Option<String>) -> Reply {
let lastfm_user = match arg { let lastfm_user = match arg {
"" => get_lastfm_username(ctx, id).await, "" => get_lastfm_username(ctx, id).await,
_ => Some(arg.to_string()), _ => Some(arg.to_string()),
}; };
let lastfm_client = match lastfm_user { let lastfm_client = lastfm::Client::<String, String>::from_env(handle_option!(lastfm_user, "No last.fm username set."));
Some(s) => lastfm::Client::<String, String>::from_env(s),
None => return Reply::Text("No last.fm username set.".to_string()),
};
let now_playing = match lastfm_client.now_playing().await { let now_playing = match lastfm_client.now_playing().await {
Ok(np) => np, Ok(np) => np,
Err(e) => return Reply::Text(format!("Error: grabbing last.fm user data failed {e}")), Err(e) => return Reply::Text(format!("Error: grabbing last.fm user data failed {e}")),
@ -126,23 +144,60 @@ 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()),
}; };
#[allow(unused_variables)]
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()),
}; };
let image = match get_image(ctx, image_uri.as_str()).await { let image_base = match get_image(ctx, image_uri.as_str()).await {
Ok(i) => i, Ok(i) => i,
Err(e) => return Reply::Text(format!("{}", e)), Err(e) => return Reply::Text(e.to_string()),
}; };
let mut base_color = PixelWand::new(); //let image_base_raw = Vec::<u8>::new();
//let png_decoder = image::codecs::png::PngDecoder::new(&mut image_base);
//let mut image_rgba8 = Vec::<u8>::new();
//let png_encoder = image::codecs::png::PngEncoder::new(&mut image_rgba8);
//png_encoder.write_image(&image_base, image)
/*let mut base_color = PixelWand::new();
let mut white = PixelWand::new(); let mut white = PixelWand::new();
let main_wand = MagickWand::new(); let main_wand = MagickWand::new();
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!( let mut main_image = handle_option!(tiny_skia::Pixmap::new(FMI_WIDTH, FMI_HEIGHT), "Failed to load main image");
let mut album_image = handle_result!(tiny_skia::Pixmap::decode_png(&image_base), "Failed to load album art image");
//let album_image = handle_result!(tiny_skia::Pixmap::decode_png(&image_rgba8), "Failed to decode album art");
#[allow(unused_variables)]
let (dom, sub) = match img::dominant_colors_as_paints(&album_image) {
Ok(c) => c,
Err(e) => return Reply::Text(e),
};
//let background_paint = handle_result!(img::color_paint_from_lab_color(dom), "Failed to create color paint");
main_image.fill_rect(tiny_skia::Rect::from_xywh(0.0, 0.0, FMI_WIDTH as f32, FMI_HEIGHT as f32).unwrap(), &dom, tiny_skia::Transform::identity(), None);
let mut pb = tiny_skia::PathBuilder::new();
pb.move_to(FMI_WIDTH as f32, 0.0);
pb.line_to((FMI_WIDTH - FMI_HEIGHT) as f32, FMI_HEIGHT as f32);
pb.line_to(FMI_WIDTH as f32, FMI_HEIGHT as f32);
pb.close();
let path = pb.finish().unwrap();
main_image.fill_path(&path, &sub, tiny_skia::FillRule::Winding, tiny_skia::Transform::identity(), None);
let art_size = FMI_HEIGHT- 2 * FMI_GAP;
album_image = handle_option!(img::scale_skia_pixmap(album_image, art_size, art_size), "Failed to scale bitmap");
//let art_scale_matrix = tiny_skia::Transform::from_transla(art_size, art_size).pre_translate(FMI_GAP as f32, FMI_GAP as f32);
let source_paint = tiny_skia::PixmapPaint {
opacity: 1.0,
blend_mode: tiny_skia::BlendMode::SourceOver,
quality: tiny_skia::FilterQuality::Bilinear,
};
main_image.draw_pixmap(FMI_GAP as i32, FMI_GAP as i32, album_image.as_ref(), &source_paint, tiny_skia::Transform::identity(), None);
/*handle_magick_result!(
base_color.set_color("#7f7f7f"), base_color.set_color("#7f7f7f"),
"Failed to set init base color" "Failed to set init base color"
); );
@ -191,7 +246,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 +257,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")),
@ -231,8 +296,21 @@ async fn fmi(ctx: &Context, arg: &str, id: UserId, avatar: Option<String>) -> Re
avatar_wand.new_image(100, 100, &white), avatar_wand.new_image(100, 100, &white),
"Failed to load dummy avatar" "Failed to load dummy avatar"
), ),
} }*/
let avatar_mask_wand = MagickWand::new(); let avatar_uri = avatar.unwrap(); // TODO: FIX THIS
let avatar_base = match get_image(ctx, avatar_uri.as_str()).await {
Ok(i) => i,
Err(e) => return Reply::Text(e.to_string()),
};
let mut avatar_image = handle_result!(tiny_skia::Pixmap::decode_png(&avatar_base), "Failed to load album art image");
let avatar_mask = handle_result!(tiny_skia::Mask::decode_png(include_bytes!("avatar_mask.png")), "Failed to load album art image");
let (avatar_x, avatar_y) = (FMI_WIDTH - FMI_AVATAR_SIZE - FMI_AVATAR_GAP, FMI_HEIGHT - FMI_AVATAR_SIZE - FMI_AVATAR_GAP);
avatar_image.apply_mask(&avatar_mask);
avatar_image = handle_option!(img::scale_skia_pixmap(avatar_image, FMI_AVATAR_SIZE, FMI_AVATAR_SIZE), "Failed to scale avatar image");
//let avatar_matrix = tiny_skia::Transform::from_scale(avatar_scale, avatar_scale).pre_translate(avatar_x as f32, avatar_y as f32);
main_image.draw_pixmap(avatar_x as i32, avatar_y as i32, avatar_image.as_ref(), &source_paint, tiny_skia::Transform::identity(), None);
/*let avatar_mask_wand = MagickWand::new();
handle_magick_result!( handle_magick_result!(
avatar_mask_wand.read_image_blob(include_bytes!("avatar_mask.png")), avatar_mask_wand.read_image_blob(include_bytes!("avatar_mask.png")),
"Failed to load avatar mask" "Failed to load avatar mask"
@ -242,7 +320,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 +328,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,21 +366,29 @@ 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),
"Failed to combine avatar image" "Failed to combine avatar image"
); );*/
Reply::Image(main_wand.write_image_blob("png").unwrap()) //Reply::Image(main_wand.write_image_blob("png").unwrap())
Reply::Image(main_image.encode_png().unwrap())
} }
async fn set(ctx: &Context, arg: &str, id: UserId) -> Reply { async fn set(ctx: &Context, arg: &str, id: UserId) -> Reply {
@ -330,16 +430,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 +453,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 +480,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!(
@ -391,22 +496,23 @@ async fn set_lastfm_username(ctx: &Context, id: UserId, user: String) {
.expect("Failed to insert"); .expect("Failed to insert");
} }
async fn get_image(ctx: &Context, url: &str) -> Result<Vec<u8>, reqwest_middleware::Error> { async fn get_image(ctx: &Context, url: &str) -> Result<Vec<u8>, String> {
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) => {
log!("response received"); log!("response received");
Ok(resp let img_raw = resp
.bytes() .bytes()
.await .await
.expect("Unable to resolve bytes") .expect("Unable to resolve bytes")
.to_vec()) .to_vec();
img::ensure_32_bit_png(&img_raw)
} }
Err(e) => Err(e), Err(e) => Err(format!("{e}")),
} }
} }
@ -414,7 +520,7 @@ async fn get_image(ctx: &Context, url: &str) -> Result<Vec<u8>, reqwest_middlewa
async fn main() { async fn main() {
START.call_once(|| { START.call_once(|| {
dotenvy::dotenv().expect("Failed to load .env"); dotenvy::dotenv().expect("Failed to load .env");
magick_wand_genesis(); //magick_wand_genesis();
}); });
let db_url = env::var("DATABASE_URL").expect("Failed to load DATABASE_URL"); let db_url = env::var("DATABASE_URL").expect("Failed to load DATABASE_URL");
@ -428,10 +534,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 +541,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
src/text.rs Normal file
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;
use crate::img;
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()),
}
}*/
#[allow(dead_code)]
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)?;
img::save_to_memory_png(&img)
}
*/