From ce18db3d14bda3bac4aa22d8e0ca45b6fd4bfe49 Mon Sep 17 00:00:00 2001 From: prescientmoon Date: Sat, 9 Nov 2024 12:22:35 +0100 Subject: [PATCH] Automatically run jacket processing This commit makes it so jacket processing is automatically run if any of it's outputs are missing from the filesystem, or if the hash of the raw jacket directory has changes since the last recorded value. Moreover, most assets and all fonts are now embedded in the binary! --- .gitignore | 4 +- Cargo.lock | 10 ++ Cargo.toml | 1 + flake.nix | 2 +- migrations/05-metadata/up.sql | 12 ++ src/arcaea/achievement.rs | 10 +- src/arcaea/chart.rs | 5 +- src/arcaea/jacket.rs | 10 +- src/assets.rs | 118 +++++++---------- src/bin/cli/command.rs | 1 - src/bin/cli/commands/analyse.rs | 2 +- src/bin/cli/commands/mod.rs | 1 - src/bin/cli/context.rs | 13 +- src/bin/cli/main.rs | 3 - src/bin/discord-bot/main.rs | 4 +- src/bin/discord-presence/main.rs | 6 +- src/bin/server/main.rs | 6 +- src/bitmap.rs | 10 +- src/commands/calc.rs | 8 +- src/commands/chart.rs | 10 +- src/commands/mod.rs | 10 +- src/commands/score.rs | 12 +- src/commands/stats.rs | 13 +- src/context/db.rs | 68 ++++++++++ src/context/hash.rs | 24 ++++ src/{context.rs => context/mod.rs} | 86 +++++-------- src/context/paths.rs | 94 ++++++++++++++ .../process_jackets.rs} | 121 +++++++++--------- src/lib.rs | 4 +- src/logs.rs | 4 +- src/recognition/hyperglass.rs | 4 +- src/recognition/ui.rs | 10 +- src/time.rs | 2 +- 33 files changed, 419 insertions(+), 269 deletions(-) create mode 100644 migrations/05-metadata/up.sql create mode 100644 src/context/db.rs create mode 100644 src/context/hash.rs rename src/{context.rs => context/mod.rs} (67%) create mode 100644 src/context/paths.rs rename src/{bin/cli/commands/prepare_jackets.rs => context/process_jackets.rs} (62%) 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 }}; }