diff --git a/.gitignore b/.gitignore index b66e2d2..601536c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,7 @@ result shimmering/data shimmering/logs -shimmering/assets/fonts -shimmering/assets/songs* -shimmering/assets/b30_background.* +shimmering/private_config target backups diff --git a/Cargo.lock b/Cargo.lock index be9b471..25fd1f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2060,6 +2060,15 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "memmap2" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3f7eed9d3848f8b98834af67102b720745c4ec028fcd0aa0239277e7de374f" +dependencies = [ + "libc", +] + [[package]] name = "mime" version = "0.3.17" @@ -3496,6 +3505,7 @@ dependencies = [ "image 0.25.2", "imageproc", "include_dir", + "memmap2", "num", "paste", "plotters", diff --git a/Cargo.toml b/Cargo.toml index 559fc6f..ccd883f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ paste = "1.0.15" discord-rich-presence = "0.2.4" reqwest = { version = "0.12.7", features = ["json"] } faer = { git = "https://github.com/sarah-ek/faer-rs", rev = "4f3eb7e65c69f7f7df3bdd93aa868d5666db3656", features = ["serde"] } +memmap2 = "0.9.5" [profile.dev.package.imageproc] opt-level = 3 diff --git a/flake.nix b/flake.nix index e00cc2c..bc24745 100644 --- a/flake.nix +++ b/flake.nix @@ -69,7 +69,7 @@ ]; LD_LIBRARY_PATH = lib.makeLibraryPath buildInputs; - SHIMMERING_FONTS_DIR = spkgs.shimmering-fonts; + SHIMMERING_FONT_DIR = spkgs.shimmering-fonts; }; # }}} } diff --git a/migrations/05-metadata/up.sql b/migrations/05-metadata/up.sql new file mode 100644 index 0000000..866fb96 --- /dev/null +++ b/migrations/05-metadata/up.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS metadata ( + -- We only a single metadata row in the entire db + id INTEGER PRIMARY KEY CHECK (id == 0), + + -- The last hash computed for the directory + -- containing all the raw jackets. If this + -- hash changes, every jacket is reprocessed. + raw_jackets_hash TEXT NOT NULL +) STRICT; + +-- Inserts initial metadata row +INSERT INTO metadata VALUES(0, ""); diff --git a/src/arcaea/achievement.rs b/src/arcaea/achievement.rs index 7aaed1d..d6067a8 100644 --- a/src/arcaea/achievement.rs +++ b/src/arcaea/achievement.rs @@ -1,8 +1,9 @@ +use std::path::PathBuf; + // {{{ Imports use anyhow::anyhow; use image::RgbaImage; -use crate::assets::get_data_dir; use crate::context::{ErrorKind, TagError, TaggedError, UserContext}; use crate::user::User; @@ -208,14 +209,17 @@ pub struct Achievement { } impl Achievement { + #[allow(unreachable_code)] + #[allow(clippy::diverging_sub_expression)] + #[allow(unused_variables)] pub fn new(goal: Goal) -> Self { let texture_name = goal.texture_name(); + let path: PathBuf = todo!("achivements root path thingy?"); Self { goal, texture: Box::leak(Box::new( image::open( - get_data_dir() - .join("achievements") + path.join("achievements") .join(format!("{texture_name}.png")), ) .unwrap_or_else(|_| { diff --git a/src/arcaea/chart.rs b/src/arcaea/chart.rs index 9313e3b..305401e 100644 --- a/src/arcaea/chart.rs +++ b/src/arcaea/chart.rs @@ -7,7 +7,7 @@ use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ValueRef}; use serde::{Deserialize, Serialize}; use crate::bitmap::Color; -use crate::context::{DbConnection, Error}; +use crate::context::Error; // }}} // {{{ Difficuly @@ -354,8 +354,7 @@ impl SongCache { } // {{{ Populate cache - pub fn new(conn: &DbConnection) -> Result { - let conn = conn.get()?; + pub fn new(conn: &rusqlite::Connection) -> Result { let mut result = Self::default(); // {{{ Songs diff --git a/src/arcaea/jacket.rs b/src/arcaea/jacket.rs index 336244e..3f4831c 100644 --- a/src/arcaea/jacket.rs +++ b/src/arcaea/jacket.rs @@ -8,7 +8,7 @@ use num::{Integer, ToPrimitive}; use serde::{Deserialize, Serialize}; use crate::arcaea::chart::{Difficulty, Jacket, SongCache}; -use crate::assets::get_asset_dir; +use crate::context::paths::ShimmeringPaths; use crate::context::Error; // }}} @@ -83,9 +83,9 @@ pub struct JacketCache { } // {{{ Read jackets -pub fn read_jackets(song_cache: &mut SongCache) -> Result<(), Error> { +pub fn read_jackets(paths: &ShimmeringPaths, song_cache: &mut SongCache) -> Result<(), Error> { let suffix = format!("_{BITMAP_IMAGE_SIZE}.jpg"); - let songs_dir = get_asset_dir().join("songs/by_id"); + let songs_dir = paths.jackets_path(); let entries = fs::read_dir(songs_dir).with_context(|| "Couldn't read songs directory")?; for entry in entries { @@ -150,8 +150,8 @@ pub fn read_jackets(song_cache: &mut SongCache) -> Result<(), Error> { impl JacketCache { // {{{ Generate - pub fn new() -> Result { - let bytes = fs::read(get_asset_dir().join("songs/recognition_matrix")) + pub fn new(paths: &ShimmeringPaths) -> Result { + let bytes = fs::read(paths.recognition_matrix_path()) .with_context(|| "Could not read jacket recognition matrix")?; let result = postcard::from_bytes(&bytes)?; diff --git a/src/assets.rs b/src/assets.rs index e725f6c..3039cf4 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -1,94 +1,61 @@ // {{{ Imports use std::cell::RefCell; -use std::env::var; -use std::path::PathBuf; -use std::str::FromStr; -use std::sync::{LazyLock, OnceLock}; +use std::sync::LazyLock; use std::thread::LocalKey; use freetype::{Face, Library}; use image::{DynamicImage, RgbaImage}; use crate::arcaea::chart::Difficulty; -use crate::timed; // }}} -// {{{ Path helpers -#[inline] -pub fn get_var(name: &str) -> String { - var(name).unwrap_or_else(|_| panic!("Missing `{name}` environment variable")) -} - -#[inline] -pub fn get_path(name: &str) -> PathBuf { - PathBuf::from_str(&get_var(name)) - .unwrap_or_else(|_| panic!("`{name}` environment variable is not a valid path")) -} - -#[inline] -pub fn get_data_dir() -> PathBuf { - get_path("SHIMMERING_DATA_DIR") -} - -#[inline] -pub fn get_config_dir() -> PathBuf { - get_path("SHIMMERING_CONFIG_DIR") -} - -#[inline] -pub fn get_asset_dir() -> PathBuf { - get_path("SHIMMERING_ASSET_DIR") -} -// }}} // {{{ Font helpers -#[inline] -fn get_font(name: &str) -> RefCell { - let fonts_dir = get_path("SHIMMERING_FONTS_DIR"); - let face = FREETYPE_LIB.with(|lib| { - lib.new_face(fonts_dir.join(name), 0) - .unwrap_or_else(|_| panic!("Could not load {} font", name)) - }); - RefCell::new(face) +pub type Font = Face<&'static [u8]>; + +macro_rules! get_font { + ($name: literal) => {{ + static FONT_CONTENTS: &[u8] = + include_bytes!(concat!(env!("SHIMMERING_FONT_DIR"), "/", $name)); + let face = FREETYPE_LIB.with(|lib| { + lib.new_memory_face2(FONT_CONTENTS, 0) + .unwrap_or_else(|_| panic!("Could not load {} font", $name)) + }); + RefCell::new(face) + }}; } #[inline] pub fn with_font( - primary: &'static LocalKey>, - f: impl FnOnce(&mut [&mut Face]) -> T, + primary: &'static LocalKey>, + f: impl FnOnce(&mut [&mut Font]) -> T, ) -> T { UNI_FONT.with_borrow_mut(|uni| primary.with_borrow_mut(|primary| f(&mut [primary, uni]))) } // }}} // {{{ Font loading -// TODO: I might want to embed those into the binary 🤔 thread_local! { pub static FREETYPE_LIB: Library = Library::init().unwrap(); -pub static EXO_FONT: RefCell = get_font("Exo[wght].ttf"); -pub static GEOSANS_FONT: RefCell = get_font("GeosansLight.ttf"); -pub static KAZESAWA_FONT: RefCell = get_font("Kazesawa-Regular.ttf"); -pub static KAZESAWA_BOLD_FONT: RefCell = get_font("Kazesawa-Bold.ttf"); -pub static UNI_FONT: RefCell = get_font("unifont.otf"); +pub static EXO_FONT: RefCell = get_font!("Exo[wght].ttf"); +pub static GEOSANS_FONT: RefCell = get_font!("GeosansLight.ttf"); +pub static KAZESAWA_FONT: RefCell = get_font!("Kazesawa-Regular.ttf"); +pub static KAZESAWA_BOLD_FONT: RefCell = get_font!("Kazesawa-Bold.ttf"); +pub static UNI_FONT: RefCell = get_font!("unifont.otf"); } // }}} // {{{ Asset art helpers -#[inline] -#[allow(dead_code)] -pub fn should_blur_jacket_art() -> bool { - var("SHIMMERING_BLUR_JACKETS").unwrap_or_default() == "1" -} - macro_rules! get_asset { ($name: ident, $path:expr) => { - get_asset!($name, $path, |d: DynamicImage| d); + get_asset!($name, $path, "SHIMMERING_ASSET_DIR", |d: DynamicImage| d); }; - ($name: ident, $path:expr, $f:expr) => { + ($name: ident, $path:expr, $env_var: literal, $f:expr) => { pub static $name: LazyLock = LazyLock::new(move || { - timed!($path, { - let image = image::open(get_asset_dir().join($path)) - .unwrap_or_else(|_| panic!("Could no read asset `{}`", $path)); - let f = $f; - f(image).into_rgba8() - }) + static IMAGE_BYTES: &[u8] = include_bytes!(concat!(env!($env_var), "/", $path)); + + let image = image::load_from_memory(&IMAGE_BYTES) + .unwrap_or_else(|_| panic!("Could no read asset `{}`", $path)); + + let f = $f; + f(image).into_rgba8() }); }; } @@ -104,22 +71,23 @@ get_asset!(PTT_EMBLEM, "ptt_emblem.png"); get_asset!( B30_BACKGROUND, "b30_background.jpg", + "SHIMMERING_PRIVATE_CONFIG_DIR", |image: DynamicImage| image.blur(7.0) ); pub fn get_difficulty_background(difficulty: Difficulty) -> &'static RgbaImage { - static CELL: OnceLock<[RgbaImage; 5]> = OnceLock::new(); - &CELL.get_or_init(|| { - timed!("load_difficulty_background", { - let assets_dir = get_asset_dir(); - Difficulty::DIFFICULTY_SHORTHANDS.map(|shorthand| { - image::open(assets_dir.join(format!("diff_{}.png", shorthand.to_lowercase()))) - .unwrap_or_else(|_| { - panic!("Could not get background for difficulty {shorthand:?}") - }) - .into_rgba8() - }) - }) - })[difficulty.to_index()] + get_asset!(PST_BACKGROUND, "diff_pst.png"); + get_asset!(PRS_BACKGROUND, "diff_prs.png"); + get_asset!(FTR_BACKGROUND, "diff_ftr.png"); + get_asset!(ETR_BACKGROUND, "diff_etr.png"); + get_asset!(BYD_BACKGROUND, "diff_byd.png"); + + [ + &PST_BACKGROUND, + &PRS_BACKGROUND, + &FTR_BACKGROUND, + &ETR_BACKGROUND, + &BYD_BACKGROUND, + ][difficulty.to_index()] } // }}} diff --git a/src/bin/cli/command.rs b/src/bin/cli/command.rs index 044df48..6f1e6ac 100644 --- a/src/bin/cli/command.rs +++ b/src/bin/cli/command.rs @@ -7,6 +7,5 @@ pub struct Cli { #[derive(clap::Subcommand)] pub enum Command { - PrepareJackets {}, Analyse(crate::commands::analyse::Args), } diff --git a/src/bin/cli/commands/analyse.rs b/src/bin/cli/commands/analyse.rs index 2a82531..85063a7 100644 --- a/src/bin/cli/commands/analyse.rs +++ b/src/bin/cli/commands/analyse.rs @@ -13,7 +13,7 @@ pub struct Args { } pub async fn run(args: Args) -> Result<(), Error> { - let mut ctx = CliContext::new(UserContext::new().await?); + let mut ctx = CliContext::new(UserContext::new()?)?; let res = magic_impl(&mut ctx, &args.files).await; ctx.handle_error(res).await?; Ok(()) diff --git a/src/bin/cli/commands/mod.rs b/src/bin/cli/commands/mod.rs index 61c28e2..c5b1f87 100644 --- a/src/bin/cli/commands/mod.rs +++ b/src/bin/cli/commands/mod.rs @@ -1,2 +1 @@ pub mod analyse; -pub mod prepare_jackets; diff --git a/src/bin/cli/context.rs b/src/bin/cli/context.rs index 5b208c1..a44d683 100644 --- a/src/bin/cli/context.rs +++ b/src/bin/cli/context.rs @@ -4,9 +4,10 @@ use std::path::PathBuf; use std::str::FromStr; extern crate shimmeringmoon; +use anyhow::Context; use poise::CreateReply; -use shimmeringmoon::assets::get_var; use shimmeringmoon::commands::discord::mock::ReplyEssence; +use shimmeringmoon::context::paths::get_var; use shimmeringmoon::context::Error; use shimmeringmoon::{commands::discord::MessageContext, context::UserContext}; // }}} @@ -22,13 +23,13 @@ pub struct CliContext { } impl CliContext { - pub fn new(data: UserContext) -> Self { - Self { + pub fn new(data: UserContext) -> anyhow::Result { + Ok(Self { data, - user_id: get_var("SHIMMERING_DISCORD_USER_ID") + user_id: get_var("SHIMMERING_DISCORD_USER_ID")? .parse() - .expect("invalid user id"), - } + .with_context(|| "$SHIMMERING_DISCORD_USER_ID contains an invalid user id")?, + }) } } diff --git a/src/bin/cli/main.rs b/src/bin/cli/main.rs index c426437..5e368ed 100644 --- a/src/bin/cli/main.rs +++ b/src/bin/cli/main.rs @@ -10,9 +10,6 @@ mod context; async fn main() -> Result<(), Error> { let cli = Cli::parse(); match cli.command { - Command::PrepareJackets {} => { - commands::prepare_jackets::run()?; - } Command::Analyse(args) => { commands::analyse::run(args).await?; } diff --git a/src/bin/discord-bot/main.rs b/src/bin/discord-bot/main.rs index b2d1b4a..d70898e 100644 --- a/src/bin/discord-bot/main.rs +++ b/src/bin/discord-bot/main.rs @@ -54,9 +54,9 @@ async fn main() { let framework = poise::Framework::builder() .setup(move |ctx, _ready, framework| { Box::pin(async move { - println!("Logged in as {}", _ready.user.name); + println!("🔒 Logged in as {}", _ready.user.name); poise::builtins::register_globally(ctx, &framework.options().commands).await?; - let ctx = UserContext::new().await?; + let ctx = UserContext::new()?; if var("SHIMMERING_REGEN_SCORES").unwrap_or_default() == "1" { timed!("generate_missing_scores", { diff --git a/src/bin/discord-presence/main.rs b/src/bin/discord-presence/main.rs index 4408949..320a55c 100644 --- a/src/bin/discord-presence/main.rs +++ b/src/bin/discord-presence/main.rs @@ -7,14 +7,14 @@ use discord_rich_presence::{DiscordIpc, DiscordIpcClient}; use shimmeringmoon::arcaea::chart::Difficulty; use shimmeringmoon::arcaea::play::PlayWithDetails; use shimmeringmoon::arcaea::score::ScoringSystem; -use shimmeringmoon::assets::get_var; +use shimmeringmoon::context::paths::get_var; use shimmeringmoon::context::Error; // }}} #[tokio::main] async fn main() -> Result<(), Error> { - let server_url = get_var("SHIMMERING_SERVER_URL"); - let client_id = get_var("SHIMMERING_DISCORD_ID"); + let server_url = get_var("SHIMMERING_SERVER_URL")?; + let client_id = get_var("SHIMMERING_DISCORD_ID")?; println!("Connecting to discord..."); let mut ipc = DiscordIpcClient::new(&client_id).map_err(|e| anyhow!("{}", e))?; diff --git a/src/bin/server/main.rs b/src/bin/server/main.rs index fcd7c0c..146e632 100644 --- a/src/bin/server/main.rs +++ b/src/bin/server/main.rs @@ -1,7 +1,7 @@ use context::AppContext; use routes::jacket::get_jacket_image; use routes::recent_plays::get_recent_play; -use shimmeringmoon::assets::get_var; +use shimmeringmoon::context::paths::get_var; use shimmeringmoon::context::{Error, UserContext}; mod context; @@ -10,7 +10,7 @@ mod routes; #[tokio::main] async fn main() -> Result<(), Error> { - let ctx = Box::leak(Box::new(UserContext::new().await?)); + let ctx = Box::leak(Box::new(UserContext::new()?)); let app = axum::Router::new() .route("/plays/latest", axum::routing::get(get_recent_play)) @@ -20,7 +20,7 @@ async fn main() -> Result<(), Error> { ) .with_state(AppContext::new(ctx)); - let port: u32 = get_var("SHIMMERING_SERVER_PORT").parse()?; + let port: u32 = get_var("SHIMMERING_SERVER_PORT")?.parse()?; let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{}", port)) .await .unwrap(); diff --git a/src/bitmap.rs b/src/bitmap.rs index 639ef94..ee88c59 100644 --- a/src/bitmap.rs +++ b/src/bitmap.rs @@ -10,11 +10,11 @@ use anyhow::anyhow; use freetype::bitmap::PixelMode; use freetype::face::{KerningMode, LoadFlag}; use freetype::ffi::{FT_Set_Var_Design_Coordinates, FT_GLYPH_BBOX_PIXELS}; -use freetype::{Bitmap, BitmapGlyph, Face, Glyph, StrokerLineCap, StrokerLineJoin}; +use freetype::{Bitmap, BitmapGlyph, Glyph, StrokerLineCap, StrokerLineJoin}; use image::{GenericImage, RgbImage, RgbaImage}; use num::traits::Euclid; -use crate::assets::FREETYPE_LIB; +use crate::assets::{Font, FREETYPE_LIB}; use crate::context::Error; // }}} @@ -304,7 +304,7 @@ impl BitmapCanvas { #[allow(clippy::type_complexity)] pub fn plan_text_rendering( pos: Position, - faces: &mut [&mut Face], + faces: &mut [&mut Font], style: TextStyle, text: &str, ) -> Result<(Position, Rect, Vec<(i64, Glyph)>), Error> { @@ -430,7 +430,7 @@ impl BitmapCanvas { pub fn text( &mut self, pos: Position, - faces: &mut [&mut Face], + faces: &mut [&mut Font], style: TextStyle, text: &str, ) -> Result<(), Error> { @@ -770,7 +770,7 @@ impl LayoutDrawer { &mut self, id: LayoutBoxId, pos: Position, - faces: &mut [&mut Face], + faces: &mut [&mut Font], style: TextStyle, text: &str, ) -> Result<(), Error> { diff --git a/src/commands/calc.rs b/src/commands/calc.rs index b1cbcdc..796cad9 100644 --- a/src/commands/calc.rs +++ b/src/commands/calc.rs @@ -3,7 +3,7 @@ use num::{FromPrimitive, Rational32}; use crate::arcaea::play::{compute_b30_ptt, get_best_plays}; use crate::arcaea::rating::{rating_as_float, rating_from_fixed, Rating}; -use crate::context::{Context, Error, TaggedError}; +use crate::context::{Error, PoiseContext, TaggedError}; use crate::recognition::fuzzy_song_name::guess_song_and_chart; use crate::user::User; @@ -20,7 +20,7 @@ use super::discord::MessageContext; subcommands("expected", "rating"), subcommand_required )] -pub async fn calc(_ctx: Context<'_>) -> Result<(), Error> { +pub async fn calc(_ctx: PoiseContext<'_>) -> Result<(), Error> { Ok(()) } // }}} @@ -114,7 +114,7 @@ mod expected_tests { /// Computes the expected score for a player of some potential on a given chart. #[poise::command(prefix_command, slash_command, user_cooldown = 1)] async fn expected( - mut ctx: Context<'_>, + mut ctx: PoiseContext<'_>, #[description = "The potential to compute the expected score for"] ptt: Option, #[rest] #[description = "Name of chart (difficulty at the end)"] @@ -169,7 +169,7 @@ mod rating_tests { /// Computes the rating (potential) of a play on a given chart. #[poise::command(prefix_command, slash_command, user_cooldown = 1)] async fn rating( - mut ctx: Context<'_>, + mut ctx: PoiseContext<'_>, score: u32, #[rest] #[description = "Name of chart (difficulty at the end)"] diff --git a/src/commands/chart.rs b/src/commands/chart.rs index a8d192a..7945882 100644 --- a/src/commands/chart.rs +++ b/src/commands/chart.rs @@ -3,7 +3,7 @@ use anyhow::anyhow; use poise::serenity_prelude::{CreateAttachment, CreateEmbed}; use crate::arcaea::{chart::Side, play::Play}; -use crate::context::{Context, Error, ErrorKind, TagError, TaggedError}; +use crate::context::{Error, ErrorKind, PoiseContext, TagError, TaggedError}; use crate::recognition::fuzzy_song_name::guess_song_and_chart; use crate::user::User; use std::io::Cursor; @@ -31,7 +31,7 @@ use super::discord::{CreateReplyExtra, MessageContext}; subcommands("info", "best", "plot"), subcommand_required )] -pub async fn chart(_ctx: Context<'_>) -> Result<(), Error> { +pub async fn chart(_ctx: PoiseContext<'_>) -> Result<(), Error> { Ok(()) } // }}} @@ -136,7 +136,7 @@ mod info_tests { /// Show a chart given it's name #[poise::command(prefix_command, slash_command, user_cooldown = 1)] async fn info( - mut ctx: Context<'_>, + mut ctx: PoiseContext<'_>, #[rest] #[description = "Name of chart (difficulty at the end)"] name: String, @@ -249,7 +249,7 @@ mod best_tests { /// Show the best score on a given chart #[poise::command(prefix_command, slash_command, user_cooldown = 1)] async fn best( - mut ctx: Context<'_>, + mut ctx: PoiseContext<'_>, #[rest] #[description = "Name of chart (difficulty at the end)"] name: String, @@ -400,7 +400,7 @@ async fn plot_impl( /// Show the best score on a given chart #[poise::command(prefix_command, slash_command, user_cooldown = 10)] async fn plot( - mut ctx: Context<'_>, + mut ctx: PoiseContext<'_>, scoring_system: Option, #[rest] #[description = "Name of chart (difficulty at the end)"] diff --git a/src/commands/mod.rs b/src/commands/mod.rs index e14401a..4dbfb40 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,17 +1,17 @@ -use crate::context::{Context, Error}; +use crate::context::{Error, PoiseContext}; +pub mod calc; pub mod chart; pub mod discord; pub mod score; pub mod stats; pub mod utils; -pub mod calc; // {{{ Help /// Show this help menu #[poise::command(prefix_command, slash_command, subcommands("scoring", "scoringz"))] pub async fn help( - ctx: Context<'_>, + ctx: PoiseContext<'_>, #[description = "Specific command to show help about"] #[autocomplete = "poise::builtins::autocomplete_command"] #[rest] @@ -33,7 +33,7 @@ pub async fn help( // {{{ Scoring help /// Explains the different scoring systems #[poise::command(prefix_command, slash_command)] -async fn scoring(ctx: Context<'_>) -> Result<(), Error> { +async fn scoring(ctx: PoiseContext<'_>) -> Result<(), Error> { static CONTENT: &str = " ## 1. Standard scoring (`standard`): This is the base-game Arcaea scoring system we all know and love! Points are awarded for each note, with a `2:1` pure:far ratio. The score is then scaled up such that `10_000_000` is the maximum. Last but not least, the number of max pures is added to the total. @@ -58,7 +58,7 @@ Most commands take an optional parameter specifying what scoring system to use. // {{{ Scoring gen-z help /// Explains the different scoring systems using gen-z slang #[poise::command(prefix_command, slash_command)] -async fn scoringz(ctx: Context<'_>) -> Result<(), Error> { +async fn scoringz(ctx: PoiseContext<'_>) -> Result<(), Error> { static CONTENT: &str = " ## 1. Standard scoring (`standard`): Alright, fam, this is the OG Arcaea scoring setup that everyone vibes with! You hit notes, you get points — easy clap. The ratio is straight up `2:1` pure:far. The score then gets a glow-up, maxing out at `10 milly`. And hold up, you even get bonus points for those max pures at the end. No cap, this is the classic way to flex your skills. diff --git a/src/commands/score.rs b/src/commands/score.rs index 6e153ca..6c49e25 100644 --- a/src/commands/score.rs +++ b/src/commands/score.rs @@ -1,7 +1,7 @@ // {{{ Imports use crate::arcaea::play::{CreatePlay, Play}; use crate::arcaea::score::Score; -use crate::context::{Context, Error, ErrorKind, TagError, TaggedError}; +use crate::context::{Error, ErrorKind, PoiseContext, TagError, TaggedError}; use crate::recognition::recognize::{ImageAnalyzer, ScoreKind}; use crate::user::User; use crate::{get_user_error, timed, try_block}; @@ -20,7 +20,7 @@ use super::discord::{CreateReplyExtra, MessageContext}; subcommands("magic", "delete", "show"), subcommand_required )] -pub async fn score(_ctx: Context<'_>) -> Result<(), Error> { +pub async fn score(_ctx: PoiseContext<'_>) -> Result<(), Error> { Ok(()) } // }}} @@ -55,7 +55,6 @@ pub async fn magic_impl( analyzer.read_score_kind(ctx.data(), &grayscale_image)? }); - // Do not use `ocr_image` because this reads the colors let difficulty = timed!("read_difficulty", { analyzer.read_difficulty(ctx.data(), &image, &grayscale_image, kind)? }); @@ -104,7 +103,6 @@ pub async fn magic_impl( // }}} // }}} // {{{ Deliver embed - let (embed, attachment) = timed!("to embed", { play.to_embed(ctx.data(), &user, song, chart, i, None)? }); @@ -193,7 +191,7 @@ mod magic_tests { /// Identify scores from attached images. #[poise::command(prefix_command, slash_command)] pub async fn magic( - mut ctx: Context<'_>, + mut ctx: PoiseContext<'_>, #[description = "Images containing scores"] files: Vec, ) -> Result<(), Error> { let res = magic_impl(&mut ctx, &files).await; @@ -326,7 +324,7 @@ mod show_tests { /// Show scores given their IDs. #[poise::command(prefix_command, slash_command)] pub async fn show( - mut ctx: Context<'_>, + mut ctx: PoiseContext<'_>, #[description = "Ids of score to show"] ids: Vec, ) -> Result<(), Error> { let res = show_impl(&mut ctx, &ids).await; @@ -451,7 +449,7 @@ mod delete_tests { /// Delete scores, given their IDs. #[poise::command(prefix_command, slash_command)] pub async fn delete( - mut ctx: Context<'_>, + mut ctx: PoiseContext<'_>, #[description = "Id of score to delete"] ids: Vec, ) -> Result<(), Error> { let res = delete_impl(&mut ctx, &ids).await; diff --git a/src/commands/stats.rs b/src/commands/stats.rs index 731b1e1..49245c4 100644 --- a/src/commands/stats.rs +++ b/src/commands/stats.rs @@ -18,7 +18,7 @@ use crate::assets::{ TOP_BACKGROUND, }; use crate::bitmap::{Align, BitmapCanvas, Color, LayoutDrawer, LayoutManager, Rect}; -use crate::context::{Context, Error, TaggedError}; +use crate::context::{Error, PoiseContext, TaggedError}; use crate::logs::debug_image_log; use crate::user::User; @@ -33,7 +33,7 @@ use super::discord::MessageContext; subcommands("meta", "b30", "bany"), subcommand_required )] -pub async fn stats(_ctx: Context<'_>) -> Result<(), Error> { +pub async fn stats(_ctx: PoiseContext<'_>) -> Result<(), Error> { Ok(()) } // }}} @@ -429,7 +429,10 @@ pub async fn b30_impl( // {{{ Discord wrapper /// Show the 30 best scores #[poise::command(prefix_command, slash_command, user_cooldown = 30)] -pub async fn b30(mut ctx: Context<'_>, scoring_system: Option) -> Result<(), Error> { +pub async fn b30( + mut ctx: PoiseContext<'_>, + scoring_system: Option, +) -> Result<(), Error> { let res = b30_impl(&mut ctx, scoring_system).await; ctx.handle_error(res).await?; Ok(()) @@ -461,7 +464,7 @@ async fn bany_impl( // {{{ Discord wrapper #[poise::command(prefix_command, slash_command, hide_in_help, global_cooldown = 5)] pub async fn bany( - mut ctx: Context<'_>, + mut ctx: PoiseContext<'_>, scoring_system: Option, width: u32, height: u32, @@ -537,7 +540,7 @@ async fn meta_impl(ctx: &mut C) -> Result<(), TaggedError> { // {{{ Discord wrapper /// Show stats about the bot itself. #[poise::command(prefix_command, slash_command, user_cooldown = 1)] -async fn meta(mut ctx: Context<'_>) -> Result<(), Error> { +async fn meta(mut ctx: PoiseContext<'_>) -> Result<(), Error> { let res = meta_impl(&mut ctx).await; ctx.handle_error(res).await?; diff --git a/src/context/db.rs b/src/context/db.rs new file mode 100644 index 0000000..74a37ea --- /dev/null +++ b/src/context/db.rs @@ -0,0 +1,68 @@ +// {{{ Imports +use anyhow::{anyhow, Context}; +use include_dir::{include_dir, Dir}; +use r2d2::Pool; +use r2d2_sqlite::SqliteConnectionManager; +use rusqlite_migration::Migrations; +use std::sync::LazyLock; + +use crate::context::hash::hash_files; +use crate::context::paths::ShimmeringPaths; +use crate::context::process_jackets::process_jackets; +// }}} + +pub type SqlitePool = r2d2::Pool; + +pub fn connect_db(paths: &ShimmeringPaths) -> anyhow::Result { + let db_path = paths.db_path(); + let mut conn = rusqlite::Connection::open(&db_path) + .with_context(|| "Could not connect to sqlite database")?; + + // {{{ Run migrations + static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations"); + static MIGRATIONS: LazyLock = LazyLock::new(|| { + Migrations::from_directory(&MIGRATIONS_DIR).expect("Could not load migrations") + }); + + MIGRATIONS + .to_latest(&mut conn) + .with_context(|| "Could not run migrations")?; + println!("✅ Ensured db schema is up to date"); + // }}} + // {{{ Check if we need to reprocess jackets + let current_raw_jackets_hash = hash_files(&paths.raw_jackets_path())?; + + // All this nonsense is so we can query without allocating + // space for the output string 💀 + let mut statement = conn.prepare("SELECT raw_jackets_hash FROM metadata")?; + let mut rows = statement.query(())?; + let prev_raw_jackets_hash = rows + .next()? + .ok_or_else(|| anyhow!("No metadata row found"))? + .get_ref("raw_jackets_hash")? + .as_str()?; + + let mut should_reprocess_jackets = true; + + if current_raw_jackets_hash != prev_raw_jackets_hash { + println!("😞 Jacket hashes do not match. Re-running the processing pipeline"); + } else if !paths.recognition_matrix_path().exists() { + println!("😞 Jacket recognition matrix not found."); + } else if !paths.jackets_path().exists() { + println!("😞 Processed jackets not found."); + } else { + println!("✅ Jacket hashes match. Skipping jacket processing"); + should_reprocess_jackets = false; + } + + if should_reprocess_jackets { + process_jackets(paths, &conn)?; + conn.prepare("UPDATE metadata SET raw_jackets_hash=?")? + .execute([current_raw_jackets_hash])?; + println!("✅ Jacket processing pipeline run succesfully"); + } + // }}} + + Pool::new(SqliteConnectionManager::file(&db_path)) + .with_context(|| "Could not open sqlite database.") +} diff --git a/src/context/hash.rs b/src/context/hash.rs new file mode 100644 index 0000000..24c1134 --- /dev/null +++ b/src/context/hash.rs @@ -0,0 +1,24 @@ +use sha2::{Digest, Sha256}; + +pub fn hash_files(path: &std::path::Path) -> anyhow::Result { + fn hash_dir_files_rec(path: &std::path::Path, hasher: &mut Sha256) -> anyhow::Result<()> { + if path.is_dir() { + for entry in std::fs::read_dir(path)? { + let path = entry?.path(); + hash_dir_files_rec(&path, hasher)?; + } + } else if path.is_file() { + let mut file = std::fs::File::open(path)?; + hasher.update(path.to_str().unwrap().as_bytes()); + std::io::copy(&mut file, hasher)?; + } + + Ok(()) + } + + let mut hasher = Sha256::default(); + hash_dir_files_rec(path, &mut hasher)?; + let res = hasher.finalize(); + let string = base16ct::lower::encode_string(&res); + Ok(string) +} diff --git a/src/context.rs b/src/context/mod.rs similarity index 67% rename from src/context.rs rename to src/context/mod.rs index 3da426a..b37691e 100644 --- a/src/context.rs +++ b/src/context/mod.rs @@ -1,22 +1,23 @@ // {{{ Imports -use include_dir::{include_dir, Dir}; -use r2d2::Pool; -use r2d2_sqlite::SqliteConnectionManager; -use rusqlite_migration::Migrations; -use std::fs; -use std::path::Path; -use std::sync::LazyLock; +use db::{connect_db, SqlitePool}; +use std::ops::Deref; use crate::arcaea::jacket::read_jackets; use crate::arcaea::{chart::SongCache, jacket::JacketCache}; -use crate::assets::{get_data_dir, EXO_FONT, GEOSANS_FONT, KAZESAWA_BOLD_FONT, KAZESAWA_FONT}; +use crate::assets::{EXO_FONT, GEOSANS_FONT, KAZESAWA_BOLD_FONT, KAZESAWA_FONT}; +use crate::context::paths::ShimmeringPaths; use crate::recognition::{hyperglass::CharMeasurements, ui::UIMeasurements}; use crate::timed; // }}} +pub mod db; +mod hash; +pub mod paths; +mod process_jackets; + // {{{ Common types pub type Error = anyhow::Error; -pub type Context<'a> = poise::Context<'a, UserContext, Error>; +pub type PoiseContext<'a> = poise::Context<'a, UserContext, Error>; // }}} // {{{ Error handling #[derive(Debug, Clone, Copy)] @@ -64,37 +65,17 @@ impl TagError for Error { } } // }}} -// {{{ DB connection -pub type DbConnection = r2d2::Pool; - -pub fn connect_db(data_dir: &Path) -> DbConnection { - fs::create_dir_all(data_dir).expect("Could not create $SHIMMERING_DATA_DIR"); - - let data_dir = data_dir.to_str().unwrap().to_owned(); - - let db_path = format!("{}/db.sqlite", data_dir); - let mut conn = rusqlite::Connection::open(&db_path).unwrap(); - static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations"); - static MIGRATIONS: LazyLock = LazyLock::new(|| { - Migrations::from_directory(&MIGRATIONS_DIR).expect("Could not load migrations") - }); - - MIGRATIONS - .to_latest(&mut conn) - .expect("Could not run migrations"); - - Pool::new(SqliteConnectionManager::file(&db_path)).expect("Could not open sqlite database.") -} -// }}} // {{{ UserContext /// Custom user data passed to all command functions #[derive(Clone)] pub struct UserContext { - pub db: DbConnection, + pub db: SqlitePool, pub song_cache: SongCache, pub jacket_cache: JacketCache, pub ui_measurements: UIMeasurements, + pub paths: ShimmeringPaths, + pub geosans_measurements: CharMeasurements, pub exo_measurements: CharMeasurements, // TODO: do we really need both after I've fixed the bug in the ocr code? @@ -104,16 +85,16 @@ pub struct UserContext { impl UserContext { #[inline] - pub async fn new() -> Result { + pub fn new() -> Result { timed!("create_context", { - let db = connect_db(&get_data_dir()); + let paths = ShimmeringPaths::new()?; + let db = connect_db(&paths)?; - let mut song_cache = SongCache::new(&db)?; + let mut song_cache = SongCache::new(db.get()?.deref())?; let ui_measurements = UIMeasurements::read()?; - let jacket_cache = JacketCache::new()?; - timed!("read_jackets", { - read_jackets(&mut song_cache)?; - }); + let jacket_cache = JacketCache::new(&paths)?; + + read_jackets(&paths, &mut song_cache)?; // {{{ Font measurements static WHITELIST: &str = "0123456789'abcdefghklmnopqrstuvwxyzABCDEFGHIJKLMNOPRSTUVWXYZ"; @@ -130,6 +111,7 @@ impl UserContext { Ok(Self { db, + paths, song_cache, jacket_cache, ui_measurements, @@ -145,24 +127,20 @@ impl UserContext { // {{{ Testing helpers #[cfg(test)] pub mod testing { + use std::cell::OnceCell; use tempfile::TempDir; + use super::*; use crate::commands::discord::mock::MockContext; - use super::*; - - pub async fn get_shared_context() -> &'static UserContext { - static CELL: tokio::sync::OnceCell = tokio::sync::OnceCell::const_new(); - CELL.get_or_init(|| async move { - // env::set_var("SHIMMERING_DATA_DIR", "") - UserContext::new().await.unwrap() - }) - .await + pub fn get_shared_context() -> &'static UserContext { + static CELL: OnceCell = OnceCell::new(); + CELL.get_or_init(|| UserContext::new().unwrap()) } - pub fn import_songs_and_jackets_from(to: &Path) { + pub fn import_songs_and_jackets_from(paths: &ShimmeringPaths, to: &Path) { let out = std::process::Command::new("scripts/copy-chart-info.sh") - .arg(get_data_dir()) + .arg(paths.data_dir()) .arg(to) .output() .expect("Could not run sh chart info copy script"); @@ -173,11 +151,11 @@ pub mod testing { ); } - pub async fn get_mock_context() -> Result<(MockContext, TempDir), Error> { - let mut data = (*get_shared_context().await).clone(); + pub fn get_mock_context() -> Result<(MockContext, TempDir), Error> { + let mut data = (*get_shared_context()).clone(); let dir = tempfile::tempdir()?; data.db = connect_db(dir.path()); - import_songs_and_jackets_from(dir.path()); + import_songs_and_jackets_from(&data.paths, dir.path()); let ctx = MockContext::new(data); Ok((ctx, dir)) @@ -202,7 +180,7 @@ pub mod testing { ($test_path:expr, $f:expr) => {{ use std::str::FromStr; - let (mut ctx, _guard) = $crate::context::testing::get_mock_context().await?; + let (mut ctx, _guard) = $crate::context::testing::get_mock_context()?; let res = $crate::user::User::create_from_context(&ctx); ctx.handle_error(res).await?; diff --git a/src/context/paths.rs b/src/context/paths.rs new file mode 100644 index 0000000..bc1e7d5 --- /dev/null +++ b/src/context/paths.rs @@ -0,0 +1,94 @@ +//! This module provides helpers for working with environment +//! variables and paths, together with a struct +//! that keeps track of all the runtime-relevant paths. + +use anyhow::Context; +use std::{path::Path, path::PathBuf, str::FromStr}; + +/// Wrapper around [std::env::var] which adds [anyhow] context around errors. +#[inline] +pub fn get_var(name: &str) -> anyhow::Result { + std::env::var(name).with_context(|| format!("Missing ${name} environment variable")) +} + +/// Reads an environment variable containing a directory path, +/// creating the directory if it doesn't exist. +pub fn get_env_dir_path(name: &str) -> anyhow::Result { + let var = get_var(name)?; + + let path = PathBuf::from_str(&var).with_context(|| format!("${name} is not a valid path"))?; + + std::fs::create_dir_all(&path).with_context(|| format!("Could not create ${name}"))?; + + Ok(path) +} + +#[derive(Clone, Debug)] +pub struct ShimmeringPaths { + /// This directory contains files that are entirely managed + /// by the runtime of the app, like databases or processed + /// jacket art. + data_dir: PathBuf, + + /// This directory contains configuration that should + /// not be public, like the directory of raw jacket art. + private_config_dir: PathBuf, + + /// This directory contains logs and other debugging info. + log_dir: PathBuf, +} + +impl ShimmeringPaths { + /// Gets all the standard paths from the environment, + /// creating every involved directory in the process. + pub fn new() -> anyhow::Result { + let res = Self { + data_dir: get_env_dir_path("SHIMMERING_DATA_DIR")?, + private_config_dir: get_env_dir_path("SHIMMERING_PRIVATE_CONFIG_DIR")?, + log_dir: get_env_dir_path("SHIMMERING_LOG_DIR")?, + }; + + Ok(res) + } + + #[inline] + pub fn data_dir(&self) -> &PathBuf { + &self.data_dir + } + + #[inline] + pub fn log_dir(&self) -> &PathBuf { + &self.log_dir + } + + #[inline] + pub fn db_path(&self) -> PathBuf { + self.data_dir.join("db.sqlite") + } + + #[inline] + pub fn jackets_path(&self) -> PathBuf { + self.data_dir.join("jackets") + } + + #[inline] + pub fn recognition_matrix_path(&self) -> PathBuf { + self.data_dir.join("recognition_matrix") + } + + #[inline] + pub fn raw_jackets_path(&self) -> PathBuf { + self.private_config_dir.join("jackets") + } +} + +/// Ensures an empty directory exists at a given path, +/// creating it if it doesn't, and emptying it's contents if it does. +pub fn create_empty_directory(path: &Path) -> anyhow::Result<()> { + if path.exists() { + std::fs::remove_dir_all(path).with_context(|| format!("Could not remove `{path:?}`"))?; + } + + std::fs::create_dir_all(path).with_context(|| format!("Could not create `{path:?}` dir"))?; + Ok(()) +} diff --git a/src/bin/cli/commands/prepare_jackets.rs b/src/context/process_jackets.rs similarity index 62% rename from src/bin/cli/commands/prepare_jackets.rs rename to src/context/process_jackets.rs index 6d9506e..343980d 100644 --- a/src/bin/cli/commands/prepare_jackets.rs +++ b/src/context/process_jackets.rs @@ -1,49 +1,56 @@ // {{{ Imports +use std::fmt::Write; use std::fs; -use std::io::{stdout, Write}; +use std::io::{stdout, Write as IOWrite}; use anyhow::{anyhow, bail, Context}; use faer::Mat; use image::imageops::FilterType; -use shimmeringmoon::arcaea::chart::{Difficulty, SongCache}; -use shimmeringmoon::arcaea::jacket::{ +use crate::arcaea::chart::{Difficulty, SongCache}; +use crate::arcaea::jacket::{ image_to_vec, read_jackets, JacketCache, BITMAP_IMAGE_SIZE, IMAGE_VEC_DIM, JACKET_RECOGNITITION_DIMENSIONS, }; -use shimmeringmoon::assets::{get_asset_dir, get_data_dir}; -use shimmeringmoon::context::{connect_db, Error}; -use shimmeringmoon::recognition::fuzzy_song_name::guess_chart_name; +use crate::context::paths::create_empty_directory; +use crate::recognition::fuzzy_song_name::guess_chart_name; + +use super::paths::ShimmeringPaths; // }}} -/// Hacky function which clears the current line of the standard output. -#[inline] -fn clear_line() { - print!("\r \r"); -} - -pub fn run() -> Result<(), Error> { - let db = connect_db(&get_data_dir()); - let mut song_cache = SongCache::new(&db)?; +/// Runs the entire jacket processing pipeline: +/// 1. Read all the jackets in the input directory, and infer +/// what song/chart they belong to. +/// 2. Save the jackets under a new file structure. The jackets +/// are saved in multiple qualities, together with a blurred version. +/// 3. Ensure we can read the entire jacket tree from the new location. +/// 4. Ensure no charts are missing a jacket. +/// 5. Create a matrix we can use for image recognition. +/// 6. Compress said matrix using singular value decomposition. +/// 7. Ensure the recognition matrix correctly detects every jacket it's given. +/// 8. Finally, save the recognition matrix on disk for future use. +pub fn process_jackets(paths: &ShimmeringPaths, conn: &rusqlite::Connection) -> anyhow::Result<()> { + let mut song_cache = SongCache::new(conn)?; let mut jacket_vector_ids = vec![]; let mut jacket_vectors = vec![]; - // {{{ Prepare directories - let songs_dir = get_asset_dir().join("songs"); - let raw_songs_dir = songs_dir.join("raw"); + // Contains a dir_name -> song_name map that's useful when debugging + // name recognition. This will get written to disk in case a missing + // jacket is detected. + let mut debug_name_mapping = String::new(); - let by_id_dir = songs_dir.join("by_id"); - if by_id_dir.exists() { - fs::remove_dir_all(&by_id_dir).with_context(|| "Could not remove `by_id` dir")?; - } - fs::create_dir_all(&by_id_dir).with_context(|| "Could not create `by_id` dir")?; + // {{{ Prepare directories + let jackets_dir = paths.jackets_path(); + let raw_jackets_dir = paths.raw_jackets_path(); + + create_empty_directory(&jackets_dir)?; // }}} // {{{ Traverse raw songs directory - let entries = fs::read_dir(&raw_songs_dir) - .with_context(|| "Couldn't read songs directory")? + let entries = fs::read_dir(&raw_jackets_dir) + .with_context(|| "Could not list contents of $SHIMMERING_PRIVATE_CONFIG/jackets")? .collect::, _>>() - .with_context(|| "Could not read member of `songs/raw`")?; + .with_context(|| "Could not read member of $SHIMMERING_PRIVATE_CONFIG/jackets")?; for (i, dir) in entries.iter().enumerate() { let raw_dir_name = dir.file_name(); @@ -54,7 +61,7 @@ pub fn run() -> Result<(), Error> { clear_line(); } - print!("{}/{}: {dir_name}", i, entries.len()); + print!(" 🕒 {}/{}: {dir_name}", i, entries.len()); stdout().flush()?; // }}} @@ -84,31 +91,15 @@ pub fn run() -> Result<(), Error> { _ => bail!("Unknown jacket suffix {}", name), }; - // Sometimes it's useful to distinguish between separate (but related) - // charts like "Vicious Heroism" and "Vicious [ANTi] Heroism" being in - // the same directory. To do this, we only allow the base jacket to refer - // to the FUTURE difficulty, unless it's the only jacket present - // (or unless we are parsing the tutorial) - let search_difficulty = difficulty; - - let (song, _) = guess_chart_name(dir_name, &song_cache, search_difficulty, true) + let (song, _) = guess_chart_name(dir_name, &song_cache, difficulty, true) .with_context(|| format!("Could not recognise chart name from '{dir_name}'"))?; - // {{{ Set up `out_dir` paths - let out_dir = { - let out = by_id_dir.join(song.id.to_string()); - if !out.exists() { - fs::create_dir_all(&out).with_context(|| { - format!( - "Could not create parent dir for song '{}' inside `by_id`", - song.title - ) - })?; - } + writeln!(debug_name_mapping, "{dir_name} -> {}", song.title)?; - out - }; - // }}} + let out_dir = jackets_dir.join(song.id.to_string()); + fs::create_dir_all(&out_dir).with_context(|| { + format!("Could not create jacket dir for song '{}'", song.title) + })?; let difficulty_string = if let Some(difficulty) = difficulty { &Difficulty::DIFFICULTY_SHORTHANDS[difficulty.to_index()].to_lowercase() @@ -119,6 +110,7 @@ pub fn run() -> Result<(), Error> { let contents: &'static _ = fs::read(file.path()) .with_context(|| format!("Could not read image for file {:?}", file.path()))? .leak(); + let image = image::load_from_memory(contents)?; let small_image = image.resize(BITMAP_IMAGE_SIZE, BITMAP_IMAGE_SIZE, FilterType::Gaussian); @@ -153,23 +145,26 @@ pub fn run() -> Result<(), Error> { // }}} clear_line(); - println!("Successfully processed jackets"); + println!(" ✅ Successfully processed jackets"); - read_jackets(&mut song_cache)?; - println!("Successfully read jackets"); + read_jackets(paths, &mut song_cache)?; + println!(" ✅ Successfully read processed jackets"); - // {{{ Warn on missing jackets + // {{{ Error out on missing jackets for chart in song_cache.charts() { if chart.cached_jacket.is_none() { - println!( - "No jacket found for '{} [{:?}]'", + let out_path = paths.log_dir().join("name_mapping.txt"); + std::fs::write(&out_path, debug_name_mapping)?; + + bail!( + "No jacket found for '{} [{:?}]'. A complete name map has been written to {out_path:?}", song_cache.lookup_song(chart.song_id)?.song, chart.difficulty ) } } - println!("No missing jackets detected"); + println!(" ✅ No missing jackets detected"); // }}} // {{{ Compute jacket vec matrix let mut jacket_matrix: Mat = Mat::zeros(IMAGE_VEC_DIM, jacket_vectors.len()); @@ -206,7 +201,7 @@ pub fn run() -> Result<(), Error> { clear_line(); } - print!("{}/{}: {song}", i, chart_count); + print!(" {}/{}: {song}", i, chart_count); if i % 5 == 0 { stdout().flush()?; @@ -233,17 +228,23 @@ pub fn run() -> Result<(), Error> { // }}} clear_line(); - println!("Successfully tested jacket recognition"); + println!(" ✅ Successfully tested jacket recognition"); // {{{ Save recognition matrix to disk { - println!("Encoded {} images", jacket_vectors.len()); + println!(" ✅ Encoded {} images", jacket_vectors.len()); let bytes = postcard::to_allocvec(&jacket_cache) .with_context(|| "Coult not encode jacket matrix")?; - fs::write(songs_dir.join("recognition_matrix"), bytes) + fs::write(paths.recognition_matrix_path(), bytes) .with_context(|| "Could not write jacket matrix")?; } // }}} Ok(()) } + +/// Hacky function which "clears" the current line of the standard output. +#[inline] +fn clear_line() { + print!("\r \r"); +} diff --git a/src/lib.rs b/src/lib.rs index 6470d20..e129ff6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,10 +9,10 @@ pub mod assets; pub mod bitmap; pub mod commands; pub mod context; -pub mod levenshtein; +mod levenshtein; pub mod logs; pub mod recognition; pub mod time; pub mod transform; pub mod user; -pub mod utils; +mod utils; diff --git a/src/logs.rs b/src/logs.rs index 565fb4a..f9e9d50 100644 --- a/src/logs.rs +++ b/src/logs.rs @@ -10,7 +10,7 @@ use std::{env, ops::Deref, path::PathBuf, sync::OnceLock, time::Instant}; use image::{DynamicImage, EncodableLayout, ImageBuffer, PixelWithColorType}; -use crate::assets::get_path; +use crate::context::paths::get_env_dir_path; #[inline] fn should_save_debug_images() -> bool { @@ -21,7 +21,7 @@ fn should_save_debug_images() -> bool { #[inline] fn get_log_dir() -> PathBuf { - get_path("SHIMMERING_LOG_DIR") + get_env_dir_path("SHIMMERING_LOG_DIR").unwrap() } #[inline] diff --git a/src/recognition/hyperglass.rs b/src/recognition/hyperglass.rs index 5c0fa89..397f40a 100644 --- a/src/recognition/hyperglass.rs +++ b/src/recognition/hyperglass.rs @@ -24,12 +24,12 @@ //! startup using my very own bitmap rendering module (`crate::bitmap`). // {{{ Imports use anyhow::{anyhow, bail}; -use freetype::Face; use image::{DynamicImage, ImageBuffer, Luma}; use imageproc::contrast::{threshold, ThresholdType}; use imageproc::region_labelling::{connected_components, Connectivity}; use num::traits::Euclid; +use crate::assets::Font; use crate::bitmap::{Align, BitmapCanvas, Color, TextStyle}; use crate::context::Error; use crate::logs::{debug_image_buffer_log, debug_image_log}; @@ -235,7 +235,7 @@ pub struct CharMeasurements { impl CharMeasurements { // {{{ Creation - pub fn from_text(face: &mut Face, string: &str, weight: Option) -> Result { + pub fn from_text(face: &mut Font, string: &str, weight: Option) -> Result { // These are bad estimates lol let style = TextStyle { stroke: None, diff --git a/src/recognition/ui.rs b/src/recognition/ui.rs index d4480d9..13b13cb 100644 --- a/src/recognition/ui.rs +++ b/src/recognition/ui.rs @@ -1,10 +1,7 @@ // {{{ Imports -use std::fs; - use anyhow::anyhow; use image::GenericImage; -use crate::assets::get_config_dir; use crate::bitmap::Rect; use crate::context::Error; // }}} @@ -103,11 +100,10 @@ impl UIMeasurements { let mut measurements = Vec::new(); let mut measurement = UIMeasurement::default(); - let path = get_config_dir().join("ui.txt"); - let contents = fs::read_to_string(path)?; + const CONTENTS: &str = include_str!(concat!(env!("SHIMMERING_CONFIG_DIR"), "/ui.txt")); // {{{ Parse measurement file - for (i, line) in contents.split('\n').enumerate() { + for (i, line) in CONTENTS.split('\n').enumerate() { let i = i % (UI_RECT_COUNT + 2); if i == 0 { for (j, str) in line.split_whitespace().enumerate().take(2) { @@ -141,7 +137,7 @@ impl UIMeasurements { } // }}} - println!("Read {} UI measurements", measurements.len()); + println!("✅ Read {} UI measurements", measurements.len()); Ok(Self { measurements }) } // }}} diff --git a/src/time.rs b/src/time.rs index f9d692a..a35763f 100644 --- a/src/time.rs +++ b/src/time.rs @@ -5,7 +5,7 @@ macro_rules! timed { let start = Instant::now(); let result = { $code }; // Execute the code block let duration = start.elapsed(); - println!("{}: {:?}", $label, duration); + println!("📊 {}: {:?}", $label, duration); result }}; }