diff --git a/data/assets/placeholder-jacket.jpg b/data/assets/placeholder-jacket.jpg new file mode 100644 index 0000000..923ec5e Binary files /dev/null and b/data/assets/placeholder-jacket.jpg differ diff --git a/src/assets.rs b/src/assets.rs new file mode 100644 index 0000000..1f76257 --- /dev/null +++ b/src/assets.rs @@ -0,0 +1,33 @@ +#![allow(dead_code)] +use std::{cell::RefCell, env::var, path::PathBuf, str::FromStr, sync::OnceLock}; + +use freetype::{Face, Library}; + +#[inline] +fn get_data_dir() -> PathBuf { + PathBuf::from_str(&var("SHIMMERING_DATA_DIR").expect("Missing `SHIMMERING_DATA_DIR` env var")) + .expect("`SHIMMERING_DATA_DIR` is not a valid path") +} + +#[inline] +fn get_font(name: &str, assets_dir: &PathBuf) -> RefCell { + let face = FREETYPE_LIB.with(|lib| { + lib.new_face(assets_dir.join(format!("{}-variable.ttf", name)), 0) + .expect(&format!("Could not load {} font", name)) + }); + RefCell::new(face) +} + +thread_local! { +pub static DATA_DIR: PathBuf = get_data_dir(); +pub static ASSETS_DIR: PathBuf = DATA_DIR.with(|p| p.join("assets")); +pub static FREETYPE_LIB: Library = Library::init().unwrap(); +pub static SAIRA_FONT: RefCell = ASSETS_DIR.with(|assets_dir| get_font("saira", assets_dir)); +pub static EXO_FONT: RefCell = ASSETS_DIR.with(|assets_dir| get_font("exo", assets_dir)); +} + +#[inline] +pub fn should_skip_jacket_art() -> bool { + static CELL: OnceLock = OnceLock::new(); + *CELL.get_or_init(|| var("SHIMMERING_NO_JACKETS").unwrap_or_default() == "1") +} diff --git a/src/bitmap.rs b/src/bitmap.rs index acac317..c3a96e2 100644 --- a/src/bitmap.rs +++ b/src/bitmap.rs @@ -1,12 +1,32 @@ use freetype::{ + bitmap::PixelMode, face::{KerningMode, LoadFlag}, - ffi::FT_GLYPH_BBOX_PIXELS, - Face, + ffi::{FT_Err_Ok, FT_Set_Var_Design_Coordinates, FT_GLYPH_BBOX_PIXELS}, + Face, FtResult, Stroker, StrokerLineCap, StrokerLineJoin, }; use num::traits::Euclid; -use crate::context::Error; +use crate::{assets::FREETYPE_LIB, context::Error}; +// {{{ Config types +pub type Color = (u8, u8, u8, u8); + +#[derive(Debug, Clone, Copy)] +pub enum Align { + Start, + Center, + End, +} + +#[derive(Debug, Clone, Copy)] +pub struct TextStyle { + pub size: u32, + pub weight: u32, + pub color: Color, + pub h_align: Align, + pub v_align: Align, +} +// }}} // {{{ BitmapCanvas pub struct BitmapCanvas { pub buffer: Box<[u8]>, @@ -15,7 +35,7 @@ pub struct BitmapCanvas { impl BitmapCanvas { // {{{ Draw pixel - pub fn set_pixel(&mut self, pos: (u32, u32), color: (u8, u8, u8, u8)) { + pub fn set_pixel(&mut self, pos: (u32, u32), color: Color) { let index = 3 * (pos.1 * self.width + pos.0) as usize; let alpha = color.3 as u32; self.buffer[index + 0] = @@ -71,7 +91,7 @@ impl BitmapCanvas { // }}} // {{{ Fill /// Fill with solid color - pub fn fill(&mut self, pos: (i32, i32), (iw, ih): (u32, u32), color: (u8, u8, u8, u8)) { + pub fn fill(&mut self, pos: (i32, i32), (iw, ih): (u32, u32), color: Color) { let height = self.buffer.len() as u32 / 3 / self.width; for dx in 0..iw { for dy in 0..ih { @@ -85,17 +105,39 @@ impl BitmapCanvas { } // }}} // {{{ Draw text + // TODO: perform gamma correction on the color interpolation. /// Render text pub fn text( &mut self, pos: (i32, i32), - face: Face, - size: u32, + face: &mut Face, + style: TextStyle, text: &str, - color: (u8, u8, u8, u8), ) -> Result<(), Error> { - face.set_char_size(0, (size as isize) << 6, 300, 300)?; + // {{{ Control weight + unsafe { + let raw = face.raw_mut() as *mut _; + let slice = [(style.weight as i64) << 16]; + // {{{ Debug logging + // let mut amaster = 0 as *mut FT_MM_Var; + // FT_Get_MM_Var(raw, &mut amaster as *mut _); + // println!("{:?}", *amaster); + // println!("{:?}", *(*amaster).axis); + // println!("{:?}", *(*amaster).namedstyle); + // }}} + + // Set variable weight + let err = FT_Set_Var_Design_Coordinates(raw, 3, slice.as_ptr()); + if err != FT_Err_Ok { + let err: FtResult<_> = Err(err.into()); + err?; + } + } + // }}} + face.set_char_size((style.size << 6) as isize, 0, 0, 0)?; + + // {{{ Compute layout let mut pen_x = 0; let kerning = face.has_kerning(); let mut previous = None; @@ -120,6 +162,8 @@ impl BitmapCanvas { previous = Some(glyph_index); } + // }}} + // {{{ Find bounding box let mut x_min = 32000; let mut y_min = 32000; let mut x_max = -32000; @@ -135,7 +179,7 @@ impl BitmapCanvas { x_min = bbox.xMin } - if bbox.xMax < x_max { + if bbox.xMax > x_max { x_max = bbox.xMax } @@ -143,7 +187,7 @@ impl BitmapCanvas { y_min = bbox.yMin } - if bbox.yMax < y_max { + if bbox.yMax > y_max { y_max = bbox.yMax } } @@ -156,19 +200,77 @@ impl BitmapCanvas { y_max = 0; } + // println!("{}, {} - {}, {}", x_min, y_min, x_max, y_max); + + // }}} + // {{{ Render glyphs for (pos_x, glyph) in &data { let b_glyph = glyph.to_bitmap(freetype::RenderMode::Normal, None)?; let bitmap = b_glyph.bitmap(); let pixel_mode = bitmap.pixel_mode()?; - println!( - "Pixel mode: {:?}, width {:?}, height {:?}, len {:?}, pen x {:?}", - pixel_mode, - bitmap.width(), - bitmap.rows(), - bitmap.buffer().len(), - pos_x - ); + assert_eq!(pixel_mode, PixelMode::Gray); + println!("starting to stroke"); + + // {{{ Blit border + let stroker = FREETYPE_LIB.with(|lib| lib.new_stroker())?; + stroker.set(1 << 6, StrokerLineCap::Round, StrokerLineJoin::Round, 0); + let sglyph = glyph.stroke(&stroker)?; + let sb_glyph = sglyph.to_bitmap(freetype::RenderMode::Normal, None)?; + let sbitmap = sb_glyph.bitmap(); + let spixel_mode = sbitmap.pixel_mode()?; + assert_eq!(spixel_mode, PixelMode::Gray); + + let iw = sbitmap.width(); + let ih = sbitmap.rows(); + println!("pitch {}, width {}, height {}", sbitmap.pitch(), iw, ih); + let height = self.buffer.len() as u32 / 3 / self.width; + let src = sbitmap.buffer(); + for dx in 0..iw { + for dy in 0..ih { + let x = pos.0 + *pos_x as i32 + dx as i32 + sb_glyph.left(); + let y = pos.1 + dy as i32 - sb_glyph.top(); + if x >= 0 && (x as u32) < self.width && y >= 0 && (y as u32) < height { + let gray = src[(dx + dy * iw) as usize]; + + let r = 255 - style.color.0; + let g = 255 - style.color.1; + let b = 255 - style.color.2; + let a = gray; + + let color = (r, g, b, a); + + self.set_pixel((x as u32, y as u32), color); + } + } + } + // }}} + // {{{ Blit + let iw = bitmap.width(); + let ih = bitmap.rows(); + let height = self.buffer.len() as u32 / 3 / self.width; + let src = bitmap.buffer(); + + for dx in 0..iw { + for dy in 0..ih { + let x = pos.0 + *pos_x as i32 + dx as i32 + b_glyph.left(); + let y = pos.1 + dy as i32 - b_glyph.top(); + if x >= 0 && (x as u32) < self.width && y >= 0 && (y as u32) < height { + let gray = src[(dx + dy * iw) as usize]; + + let r = style.color.0; + let g = style.color.1; + let b = style.color.2; + let a = gray; + + let color = (r, g, b, a); + + self.set_pixel((x as u32, y as u32), color); + } + } + } + // }}} } + // }}} Ok(()) } @@ -362,7 +464,7 @@ impl LayoutDrawer { // {{{ Drawing // {{{ Draw pixel - pub fn set_pixel(&mut self, id: LayoutBoxId, pos: (u32, u32), color: (u8, u8, u8, u8)) { + pub fn set_pixel(&mut self, id: LayoutBoxId, pos: (u32, u32), color: Color) { let pos = self .layout .position_relative_to(id, (pos.0 as i32, pos.1 as i32)); @@ -385,7 +487,7 @@ impl LayoutDrawer { // }}} // {{{ Fill /// Fills with solid color - pub fn fill(&mut self, id: LayoutBoxId, color: (u8, u8, u8, u8)) { + pub fn fill(&mut self, id: LayoutBoxId, color: Color) { let current = self.layout.lookup(id); self.canvas .fill((current.0, current.1), (current.2, current.3), color); @@ -397,13 +499,12 @@ impl LayoutDrawer { &mut self, id: LayoutBoxId, pos: (i32, i32), - face: Face, - size: u32, + face: &mut Face, + style: TextStyle, text: &str, - color: (u8, u8, u8, u8), ) -> Result<(), Error> { let pos = self.layout.position_relative_to(id, pos); - self.canvas.text(pos, face, size, text, color) + self.canvas.text(pos, face, style, text) } // }}} // }}} diff --git a/src/commands/stats.rs b/src/commands/stats.rs index 9a8dc21..d56e11c 100644 --- a/src/commands/stats.rs +++ b/src/commands/stats.rs @@ -17,7 +17,8 @@ use poise::{ use sqlx::query_as; use crate::{ - bitmap::{BitmapCanvas, LayoutDrawer, LayoutManager}, + assets::EXO_FONT, + bitmap::{Align, BitmapCanvas, LayoutDrawer, LayoutManager}, chart::{Chart, Song}, context::{Context, Error}, jacket::BITMAP_IMAGE_SIZE, @@ -354,18 +355,22 @@ pub async fn b30(ctx: Context<'_>) -> Result<(), Error> { } else { 0 }; - // jacket_area.draw_text( - // &chart.level, - // &TextStyle::from(("Exo", 30).into_font()) - // .color(&WHITE) - // .with_anchor::(Pos { - // h_pos: HPos::Center, - // v_pos: VPos::Center, - // }) - // .into_text_style(&jacket_area), - // (BITMAP_IMAGE_SIZE as i32 + x_offset, 2), - // )?; - // }}} + + // EXO_FONT.with_borrow_mut(|font| { + // drawer.text( + // jacket_area, + // (BITMAP_IMAGE_SIZE as i32 + x_offset - 30, 2), + // font, + // crate::bitmap::TextStyle { + // size: 40, + // weight: 250, + // color: (0xff, 0xff, 0xff, 0xff), + // h_align: Align::Center, + // v_align: Align::Center, + // }, + // &chart.level, + // ) + // })?; // {{{ Display chart name // Draw background drawer.fill(bottom_area, (0x82, 0x71, 0xA7, 255)); diff --git a/src/jacket.rs b/src/jacket.rs index 4267bd2..8e713f9 100644 --- a/src/jacket.rs +++ b/src/jacket.rs @@ -1,12 +1,11 @@ use std::{fs, path::PathBuf, str::FromStr}; -use freetype::{Face, Library}; use image::{imageops::FilterType, GenericImageView, ImageBuffer, Rgb, Rgba}; use kd_tree::{KdMap, KdPoint}; use num::Integer; use crate::{ - bitmap::BitmapCanvas, + assets::should_skip_jacket_art, chart::{Difficulty, Jacket, SongCache}, context::Error, score::guess_chart_name, @@ -88,6 +87,7 @@ impl JacketCache { // This is a bit inefficient (using a hash set), but only runs once pub fn new(data_dir: &PathBuf, song_cache: &mut SongCache) -> Result { let jacket_dir = data_dir.join("jackets"); + let assets_dir = data_dir.join("assets"); if jacket_dir.exists() { fs::remove_dir_all(&jacket_dir).expect("Could not delete jacket dir"); @@ -95,118 +95,121 @@ impl JacketCache { fs::create_dir_all(&jacket_dir).expect("Could not create jacket dir"); - let mut jackets = Vec::new(); - let entries = fs::read_dir(data_dir.join("songs")).expect("Couldn't read songs directory"); - for (i, entry) in entries.enumerate() { - let dir = entry?; - let raw_dir_name = dir.file_name(); - let dir_name = raw_dir_name.to_str().unwrap(); - for entry in fs::read_dir(dir.path()).expect("Couldn't read song directory") { - let file = entry?; - let raw_name = file.file_name(); - let name = raw_name.to_str().unwrap().strip_suffix(".jpg").unwrap(); + let tree_entries = if should_skip_jacket_art() { + let path = assets_dir.join("placeholder-jacket.jpg"); + let contents: &'static _ = fs::read(path)?.leak(); + let image = image::load_from_memory(contents)?; + let bitmap: &'static _ = Box::leak(Box::new( + image + .resize(BITMAP_IMAGE_SIZE, BITMAP_IMAGE_SIZE, FilterType::Nearest) + .into_rgb8(), + )); - if !name.ends_with("_256") { - continue; - } - let name = name.strip_suffix("_256").unwrap(); - - let difficulty = match name { - "0" => Some(Difficulty::PST), - "1" => Some(Difficulty::PRS), - "2" => Some(Difficulty::FTR), - "3" => Some(Difficulty::BYD), - "4" => Some(Difficulty::ETR), - "base" => None, - "base_night" => None, - "base_ja" => None, - _ => Err(format!("Unknown jacket suffix {}", name))?, - }; - - let (song, chart) = guess_chart_name(dir_name, &song_cache, difficulty, true)?; - - jackets.push((file.path(), song.id)); - - let contents = fs::read(file.path())?.leak(); - let bitmap = Box::leak(Box::new( - image::load_from_memory(contents)? - .resize(BITMAP_IMAGE_SIZE, BITMAP_IMAGE_SIZE, FilterType::Nearest) - .into_rgb8(), - )); - - if name == "base" { - let item = song_cache.lookup_mut(song.id).unwrap(); - - for chart in item.charts_mut() { - let difficulty_num = match chart.difficulty { - Difficulty::PST => "0", - Difficulty::PRS => "1", - Difficulty::FTR => "2", - Difficulty::BYD => "3", - Difficulty::ETR => "4", - }; - - // We only want to create this path if there's no overwrite for this - // jacket. - let specialized_path = PathBuf::from_str( - &file - .path() - .to_str() - .unwrap() - .replace("base_night", difficulty_num) - .replace("base", difficulty_num), - ) - .unwrap(); - - let dest = chart.jacket_path(data_dir); - if !specialized_path.exists() && !dest.exists() { - std::os::unix::fs::symlink(file.path(), dest) - .expect("Could not symlink jacket"); - chart.cached_jacket = Some(Jacket { - raw: contents, - bitmap, - }); - } - } - } else if difficulty.is_some() { - std::os::unix::fs::symlink(file.path(), chart.jacket_path(data_dir)) - .expect("Could not symlink jacket"); - let chart = song_cache.lookup_chart_mut(chart.id).unwrap(); + for song in song_cache.songs_mut() { + for chart in song.charts_mut() { chart.cached_jacket = Some(Jacket { raw: contents, bitmap, }); } } - } - let mut entries = vec![]; + Vec::new() + } else { + let entries = + fs::read_dir(data_dir.join("songs")).expect("Couldn't read songs directory"); + let mut tree_entries = vec![]; - for (path, song_id) in jackets { - match image::io::Reader::open(path) { - Ok(reader) => { - let image = reader.decode()?; - entries.push((ImageVec::from_image(&image), song_id)) + for entry in entries { + let dir = entry?; + let raw_dir_name = dir.file_name(); + let dir_name = raw_dir_name.to_str().unwrap(); + for entry in fs::read_dir(dir.path()).expect("Couldn't read song directory") { + let file = entry?; + let raw_name = file.file_name(); + let name = raw_name.to_str().unwrap().strip_suffix(".jpg").unwrap(); + + if !name.ends_with("_256") { + continue; + } + let name = name.strip_suffix("_256").unwrap(); + + let difficulty = match name { + "0" => Some(Difficulty::PST), + "1" => Some(Difficulty::PRS), + "2" => Some(Difficulty::FTR), + "3" => Some(Difficulty::BYD), + "4" => Some(Difficulty::ETR), + "base" => None, + "base_night" => None, + "base_ja" => None, + _ => Err(format!("Unknown jacket suffix {}", name))?, + }; + + let (song, chart) = guess_chart_name(dir_name, &song_cache, difficulty, true)?; + + let contents: &'static _ = fs::read(file.path())?.leak(); + + let image = image::load_from_memory(contents)?; + tree_entries.push((ImageVec::from_image(&image), song.id)); + + let bitmap: &'static _ = Box::leak(Box::new( + image + .resize(BITMAP_IMAGE_SIZE, BITMAP_IMAGE_SIZE, FilterType::Nearest) + .into_rgb8(), + )); + + if name == "base" { + let item = song_cache.lookup_mut(song.id).unwrap(); + + for chart in item.charts_mut() { + let difficulty_num = match chart.difficulty { + Difficulty::PST => "0", + Difficulty::PRS => "1", + Difficulty::FTR => "2", + Difficulty::BYD => "3", + Difficulty::ETR => "4", + }; + + // We only want to create this path if there's no overwrite for this + // jacket. + let specialized_path = PathBuf::from_str( + &file + .path() + .to_str() + .unwrap() + .replace("base_night", difficulty_num) + .replace("base", difficulty_num), + ) + .unwrap(); + + let dest = chart.jacket_path(data_dir); + if !specialized_path.exists() && !dest.exists() { + std::os::unix::fs::symlink(file.path(), dest) + .expect("Could not symlink jacket"); + chart.cached_jacket = Some(Jacket { + raw: contents, + bitmap, + }); + } + } + } else if difficulty.is_some() { + std::os::unix::fs::symlink(file.path(), chart.jacket_path(data_dir)) + .expect("Could not symlink jacket"); + let chart = song_cache.lookup_chart_mut(chart.id).unwrap(); + chart.cached_jacket = Some(Jacket { + raw: contents, + bitmap, + }); + } } - _ => continue, } - } - let assets_dir = data_dir.join("assets"); - - let lib = Library::init()?; - let saira_font = lib.new_face(assets_dir.join("saira-variable.ttf"), 0)?; - let mut canvas = BitmapCanvas::new(0, 0); - canvas.text( - (0, 0), - saira_font, - 20, - "Yo, this is a test!", - (0, 0, 0, 0xff), - )?; + tree_entries + }; let result = Self { - tree: KdMap::build_by_ordered_float(entries), + tree: KdMap::build_by_ordered_float(tree_entries), b30_background: image::open(assets_dir.join("b30_background.jpg"))? .resize(2048 * 2, 1535 * 2, FilterType::Nearest) .blur(20.0) diff --git a/src/main.rs b/src/main.rs index 8b88cc5..3a29ab4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ #![feature(array_try_map)] #![feature(async_closure)] +mod assets; mod bitmap; mod chart; mod commands; @@ -12,6 +13,7 @@ mod jacket; mod score; mod user; +use assets::DATA_DIR; use context::{Error, UserContext}; use poise::serenity_prelude::{self as serenity}; use sqlx::sqlite::SqlitePoolOptions; @@ -31,11 +33,14 @@ async fn on_error(error: poise::FrameworkError<'_, UserContext, Error>) { #[tokio::main] async fn main() { - let data_dir = var("SHIMMERING_DATA_DIR").expect("Missing `SHIMMERING_DATA_DIR` env var"); + let data_dir = DATA_DIR.with(|d| d.clone()); let cache_dir = var("SHIMMERING_CACHE_DIR").expect("Missing `SHIMMERING_CACHE_DIR` env var"); let pool = SqlitePoolOptions::new() - .connect(&format!("sqlite://{}/db.sqlite", data_dir)) + .connect(&format!( + "sqlite://{}/db.sqlite", + data_dir.to_str().unwrap() + )) .await .unwrap(); @@ -80,12 +85,7 @@ async fn main() { Box::pin(async move { println!("Logged in as {}", _ready.user.name); poise::builtins::register_globally(ctx, &framework.options().commands).await?; - let ctx = UserContext::new( - PathBuf::from_str(&data_dir)?, - PathBuf::from_str(&cache_dir)?, - pool, - ) - .await?; + let ctx = UserContext::new(data_dir, PathBuf::from_str(&cache_dir)?, pool).await?; Ok(ctx) })