Compare commits

...

14 Commits

34 changed files with 3088 additions and 322 deletions

3
.env.example Normal file
View File

@ -0,0 +1,3 @@
DISCORD_TOKEN=
LASTFM_API_KEY=
DATABASE_URL=sqlite:watcat.db

2
.gitignore vendored
View File

@ -1,2 +1,4 @@
/target
/.env
/watcat.db*
/http-cacache

4
.gitmodules vendored Normal file
View File

@ -0,0 +1,4 @@
[submodule "magick-rust"]
path = magick-rust
url = https://git.starbit.dev/seoxi/magick-rust
branch = sr

2458
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,33 @@
[package]
name = "watcat"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
[dependencies]
serenity = "0.12"
tokio = { version = "1.21.2", features = ["macros", "rt-multi-thread"] }
dotenv = "0.15.0"
lastfm = "0.10.0"
magick_rust = "1.0.0" #{ path = "../magick-rust" }
dotenvy = "0.15.7"
reqwest = "0.12"
reqwest-middleware = "0.4.2"
http-cache-reqwest = "0.15.1"
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]
#path = "./magick-rust"
[dependencies.tokio]
version = "1.21.2"
features = ["macros", "rt-multi-thread"]
[dependencies.sqlx]
version = "0.8.5"
features = ["sqlite", "runtime-tokio"]

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,9 +8,27 @@ 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
-------------------
I'm trying, okay?
running
-------
populate .env with necessary values. a template .env.example is given
create the database ``sqlx database create``
(note: make sure you have sqlx installed first)
init the database ``sqlx migrate run``
TODO
----
+ ~~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.

1
magick-rust Submodule

Submodule magick-rust added at 5fbc506983

View File

@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS users
(
discord_userid INTEGER PRIMARY KEY NOT NULL,
lastfm_username TEXT NOT NULL
) STRICT;

BIN
src/accent_mask.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 B

BIN
src/avatar_mask.png Normal file

Binary file not shown.

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,141 +1,587 @@
//#![allow(dead_code)]
//#![allow(unused_variables)]
//#![allow(unused_imports)]
//#![allow(unused_macros)]
extern crate dotenvy;
extern crate lastfm;
use dotenv::dotenv;
extern crate reqwest;
extern crate reqwest_middleware;
use http_cache_reqwest::{CACacheManager, Cache, CacheMode, HttpCache, HttpCacheOptions};
use serenity::async_trait;
use serenity::model::channel::Message;
use serenity::prelude::*;
use serenity::builder::{CreateAttachment, CreateMessage};
use serenity::model::channel::Message;
use serenity::model::id::UserId;
use serenity::prelude::*;
extern crate magick_rust;
use magick_rust::{magick_wand_genesis, MagickWand, PixelWand, CompositeOperator, ColorspaceType};
extern crate tiny_skia;
extern crate image;
//extern crate magick_rust;
//use magick_rust::{ColorspaceType, CompositeOperator, MagickWand, PixelWand, magick_wand_genesis, FilterType};
use std::env;
use std::sync::Once;
//use palette::color_difference::ImprovedCiede2000;
//use palette::lab::Lab;
//use palette::white_point::D65;
extern crate sqlx;
use sqlx::sqlite::SqlitePool;
use imagetext::fontdb::FontDB;
use imagetext::superfont::SuperFont;
mod text;
mod img;
static START: Once = Once::new();
struct Handler;
enum Reply {
Image(Vec<u8>),
Text(String)
Image(Vec<u8>),
Text(String),
}
macro_rules! handle_magick_result {
($a: expr, $b: literal) => {
match $a {
Ok(_) => {},
Err(e) => return Reply::Text(format!("Error: {} {}", $b, e)),
macro_rules! now {
() => {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs_f32() as usize
};
}
macro_rules! log {
($a:expr) => {
if env::var("DEBUG").unwrap_or("0".to_string()) == "1" {
println!(concat!("debug: {} ", $a), now!())
}
}
}
macro_rules! handle_magick_option {
($a: expr, $b: literal) => {
match $a {
Some(res) => res,
None => return Reply::Text($b.to_string()),
}
}
}
async fn fmi(_arg: &str) -> Reply {
let client = lastfm::Client::<String, &str>::from_env("seoxi");
let now_playing = match client.now_playing().await {
Ok(np) => np,
Err(e) => return Reply::Text(format!("Error: grabbing last.fm user data failed {e}")),
};
if let Some(track) = now_playing {
let image_uri = match track.image.extralarge {
Some(iu) => iu,
None => return Reply::Text("Error: getting image uri failed".to_string()),
};
let mut base_color = PixelWand::new();
#[allow(unused_mut)]
let mut main_wand = MagickWand::new();
#[allow(unused_mut)]
let mut art_wand = MagickWand::new();
//#[allow(unused_mut)]
let mut art_wand_cluster = MagickWand::new();
handle_magick_result!(base_color.set_color("#7f7f7f"), "Failed to set base color");
handle_magick_result!(art_wand.read_image(image_uri.as_str()), "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(5, 200, 0.001), "Failed to run kmeans");
let mut colors = handle_magick_option!(art_wand_cluster.get_image_histogram(), "Failed to get color histogram");
println!("{}", colors.len());
colors.sort_by_cached_key(|color| -(color.get_color_count() as isize));
handle_magick_result!(main_wand.new_image(548, 147, &colors[0]), "Failed to create wand");
handle_magick_result!(main_wand.transform_image_colorspace(ColorspaceType::Lab), "Failed to set main colorspace");
handle_magick_result!(art_wand.adaptive_resize_image(124, 124), "Failed to resize art");
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 images");
handle_magick_result!(main_wand.compose_images(&art_wand_cluster, CompositeOperator::SrcOver, false, 160, 12), "Failed to combine images");
Reply::Image(main_wand.write_image_blob("png").unwrap())
} else {
Reply::Text("Nothing playing.".to_string())
}
($a:expr, $($b:expr),+) => {
if env::var("DEBUG").unwrap_or("0".to_string()) == "1" {
println!(concat!("debug: {} ", $a), now!(), $($b),+)
}
};
}
async fn set(arg: &str) -> Reply {
Reply::Text(format!("set user {}", arg))
macro_rules! handle_result {
($a: expr, $b: literal) => {
match $a {
Ok(a) => {
log!("{} run successfully", stringify!($a));
a
}
Err(e) => return Reply::Text(format!("Error: {} {}", $b, e)),
}
};
}
macro_rules! handle_option {
($a: expr, $b: literal) => {
match $a {
Some(res) => res,
None => return Reply::Text($b.to_string()),
}
};
}
/*
// 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
fn parse_cielab(s: String) -> Result<(f32, f32, f32), &'static str> {
if s.len() < 13 || &s[0..7] != "cielab(" || s.chars().nth(s.len() - 1).unwrap() != ')' {
return Err("Error parsing cielab string");
}
let vals = s[7..(s.len() - 1)]
.split(",")
.map(|x| match x.parse() {
Ok(f) => f,
Err(_) => f32::NAN,
})
.collect::<Vec<f32>>();
if vals.iter().any(|x| x.is_nan()) {
return Err("Error parsing cielab string");
}
match &vals[..] {
&[a, b, c] => Ok((a, b, c)),
_ => Err("Error parsing cielab string"),
}
}*/
/*fn validate_color(col: &PixelWand) -> Option<Lab> {
let color_str = match col.get_color_as_string() {
Ok(s) => s,
Err(_) => return None,
};
let color_raw = match parse_cielab(color_str) {
Ok(f) => f,
Err(_) => return None,
};
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 {
let lastfm_user = match arg {
"" => get_lastfm_username(ctx, id).await,
_ => Some(arg.to_string()),
};
let lastfm_client = lastfm::Client::<String, String>::from_env(handle_option!(lastfm_user, "No last.fm username set."));
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}")),
};
let track = match now_playing {
Some(track) => track,
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 {
Some(iu) => iu,
None => return Reply::Text("Error: getting image uri failed".to_string()),
};
let image_base = match get_image(ctx, image_uri.as_str()).await {
Ok(i) => i,
Err(e) => return Reply::Text(e.to_string()),
};
//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 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();*/
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"),
"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 (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("cielab(0.0,0.0,0.0)"),
"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"
);
}
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")),
"Failed to load mask"
);
handle_magick_result!(
mask_wand.opaque_paint_image(&white, &accent_color, 0.0, false),
"Failed to paint accent mask"
);
let avatar_wand = MagickWand::new();
match avatar {
Some(avatar_uri) => {
if let Ok(avatar) = get_image(ctx, avatar_uri.as_str()).await {
handle_magick_result!(
avatar_wand.read_image_blob(avatar),
"Failed to load user avatar"
);
} else {
handle_magick_result!(
avatar_wand.new_image(100, 100, &white),
"Failed to load dummy avatar"
);
}
}
None => handle_magick_result!(
avatar_wand.new_image(100, 100, &white),
"Failed to load dummy avatar"
),
}*/
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!(
avatar_mask_wand.read_image_blob(include_bytes!("avatar_mask.png")),
"Failed to load avatar mask"
);
handle_magick_result!(
avatar_wand.compose_images(&avatar_mask_wand, CompositeOperator::CopyAlpha, false, 0, 0),
"Failed to mask avatar"
);
handle_magick_result!(
avatar_wand.resize_image(64, 64, FilterType::RobidouxSharp),
"Failed to resize avatar"
);
handle_magick_result!(
avatar_wand.transform_image_colorspace(ColorspaceType::Lab),
"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!(
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!(
main_wand.transform_image_colorspace(ColorspaceType::Lab),
"Failed to set main colorspace"
);
handle_magick_result!(
art_wand.adaptive_resize_image(124, 124),
"Failed to resize art"
);
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"
);
handle_magick_result!(
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(&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),
"Failed to combine avatar image"
);*/
//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 {
set_lastfm_username(ctx, id, arg.to_string()).await;
Reply::Text(format!("set user {}", arg))
}
#[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" => Some(fmi(arg).await),
".set" => Some(set(arg).await),
_ => None
};
if let Some(reply) = resp {
let m = match reply {
Reply::Image(img) => {
let file = CreateAttachment::bytes(img, "fmi.png");
CreateMessage::new().add_file(file)
},
Reply::Text(txt) => CreateMessage::new().content(txt)
};
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" => {
log!("{} received", msg.content);
Some(fmi(&ctx, arg, msg.author.id, msg.author.avatar_url()).await)
}
".set" => {
log!("{} received", msg.content);
Some(set(&ctx, arg, msg.author.id).await)
}
_ => None,
};
if let Some(reply) = resp {
let m = match reply {
Reply::Image(img) => {
let file = CreateAttachment::bytes(img, "fmi.png");
CreateMessage::new().add_file(file)
}
Reply::Text(txt) => CreateMessage::new().content(txt),
};
let message = msg.channel_id.send_message(&ctx.http, m).await;
if let Err(why) = message {
println!("Error sending message: {why:?}");
}
}
let message = msg.channel_id.send_message(&ctx.http, m).await;
if let Err(why) = message {
println!("Error sending message: {why:?}");
}
}
}
}
struct HttpHaver;
impl TypeMapKey for HttpHaver {
type Value = reqwest_middleware::ClientWithMiddleware;
}
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,
}
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::<PoolHaver>()
.expect("Failed to get pool container");
let id = i64::from(id);
let resp = sqlx::query_as!(
DBResponse,
r#"
SELECT lastfm_username
FROM users
WHERE discord_userid = ?1
"#,
id
)
.fetch_one(pool)
.await;
match resp {
Ok(r) => {
log!("got user {}", r.lastfm_username);
Some(r.lastfm_username)
}
Err(_) => None,
}
}
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::<PoolHaver>()
.expect("Failed to get pool container");
let id = i64::from(id);
sqlx::query!(
r#"
INSERT INTO users
VALUES (?1, ?2)
"#,
id,
user
)
.execute(pool)
.await
.expect("Failed to insert");
}
async fn get_image(ctx: &Context, url: &str) -> Result<Vec<u8>, String> {
log!("get {}", url);
let data = ctx.data.write().await;
let http = data
.get::<HttpHaver>()
.expect("Failed to get http client");
match http.get(url).send().await {
Ok(resp) => {
log!("response received");
let img_raw = resp
.bytes()
.await
.expect("Unable to resolve bytes")
.to_vec();
img::ensure_32_bit_png(&img_raw)
}
Err(e) => Err(format!("{e}")),
}
}
#[tokio::main]
async fn main() {
START.call_once(|| {
dotenv().ok();
magick_wand_genesis();
START.call_once(|| {
dotenvy::dotenv().expect("Failed to load .env");
//magick_wand_genesis();
});
// Login with a bot token from the environment
let token = env::var("DISCORD_TOKEN").expect("Expected a token in the environment");
let db_url = env::var("DATABASE_URL").expect("Failed to load DATABASE_URL");
let pool = SqlitePool::connect(&db_url)
.await
.expect("Failed to load db");
let token = env::var("DISCORD_TOKEN").expect("Failed to load DISCORD_TOKEN");
// Set gateway intents, which decides what events the bot will be notified about
let intents = GatewayIntents::GUILD_MESSAGES
| GatewayIntents::DIRECT_MESSAGES
| GatewayIntents::MESSAGE_CONTENT;
// Create a new instance of the Client, logging in as a bot.
let mut client =
Client::builder(&token, intents).event_handler(Handler).await.expect("Err creating client");
let http = reqwest_middleware::ClientBuilder::new(reqwest::Client::new())
.with(Cache(HttpCache {
mode: CacheMode::Default,
manager: CACacheManager::default(),
options: HttpCacheOptions::default(),
}))
.build();
// Start listening for events by starting a single shard
if let Err(why) = client.start().await {
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::<PoolHaver>(pool);
data.insert::<HttpHaver>(http);
data.insert::<FontsHaver>((regular_fonts, bold_fonts));
}
if let Err(why) = discord_client.start().await {
println!("Client error: {why:?}");
}
}

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)
}
*/