1
Fork 0

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!
This commit is contained in:
prescientmoon 2024-11-09 12:22:35 +01:00
parent f56da9a082
commit ce18db3d14
Signed by: prescientmoon
SSH key fingerprint: SHA256:WFp/cO76nbarETAoQcQXuV+0h7XJsEsOCI0UsyPIy6U
33 changed files with 419 additions and 269 deletions

4
.gitignore vendored
View file

@ -4,9 +4,7 @@ result
shimmering/data shimmering/data
shimmering/logs shimmering/logs
shimmering/assets/fonts shimmering/private_config
shimmering/assets/songs*
shimmering/assets/b30_background.*
target target
backups backups

10
Cargo.lock generated
View file

@ -2060,6 +2060,15 @@ version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "memmap2"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3f7eed9d3848f8b98834af67102b720745c4ec028fcd0aa0239277e7de374f"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "mime" name = "mime"
version = "0.3.17" version = "0.3.17"
@ -3496,6 +3505,7 @@ dependencies = [
"image 0.25.2", "image 0.25.2",
"imageproc", "imageproc",
"include_dir", "include_dir",
"memmap2",
"num", "num",
"paste", "paste",
"plotters", "plotters",

View file

@ -53,6 +53,7 @@ paste = "1.0.15"
discord-rich-presence = "0.2.4" discord-rich-presence = "0.2.4"
reqwest = { version = "0.12.7", features = ["json"] } reqwest = { version = "0.12.7", features = ["json"] }
faer = { git = "https://github.com/sarah-ek/faer-rs", rev = "4f3eb7e65c69f7f7df3bdd93aa868d5666db3656", features = ["serde"] } faer = { git = "https://github.com/sarah-ek/faer-rs", rev = "4f3eb7e65c69f7f7df3bdd93aa868d5666db3656", features = ["serde"] }
memmap2 = "0.9.5"
[profile.dev.package.imageproc] [profile.dev.package.imageproc]
opt-level = 3 opt-level = 3

View file

@ -69,7 +69,7 @@
]; ];
LD_LIBRARY_PATH = lib.makeLibraryPath buildInputs; LD_LIBRARY_PATH = lib.makeLibraryPath buildInputs;
SHIMMERING_FONTS_DIR = spkgs.shimmering-fonts; SHIMMERING_FONT_DIR = spkgs.shimmering-fonts;
}; };
# }}} # }}}
} }

View file

@ -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, "");

View file

@ -1,8 +1,9 @@
use std::path::PathBuf;
// {{{ Imports // {{{ Imports
use anyhow::anyhow; use anyhow::anyhow;
use image::RgbaImage; use image::RgbaImage;
use crate::assets::get_data_dir;
use crate::context::{ErrorKind, TagError, TaggedError, UserContext}; use crate::context::{ErrorKind, TagError, TaggedError, UserContext};
use crate::user::User; use crate::user::User;
@ -208,14 +209,17 @@ pub struct Achievement {
} }
impl Achievement { impl Achievement {
#[allow(unreachable_code)]
#[allow(clippy::diverging_sub_expression)]
#[allow(unused_variables)]
pub fn new(goal: Goal) -> Self { pub fn new(goal: Goal) -> Self {
let texture_name = goal.texture_name(); let texture_name = goal.texture_name();
let path: PathBuf = todo!("achivements root path thingy?");
Self { Self {
goal, goal,
texture: Box::leak(Box::new( texture: Box::leak(Box::new(
image::open( image::open(
get_data_dir() path.join("achievements")
.join("achievements")
.join(format!("{texture_name}.png")), .join(format!("{texture_name}.png")),
) )
.unwrap_or_else(|_| { .unwrap_or_else(|_| {

View file

@ -7,7 +7,7 @@ use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ValueRef};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::bitmap::Color; use crate::bitmap::Color;
use crate::context::{DbConnection, Error}; use crate::context::Error;
// }}} // }}}
// {{{ Difficuly // {{{ Difficuly
@ -354,8 +354,7 @@ impl SongCache {
} }
// {{{ Populate cache // {{{ Populate cache
pub fn new(conn: &DbConnection) -> Result<Self, Error> { pub fn new(conn: &rusqlite::Connection) -> Result<Self, Error> {
let conn = conn.get()?;
let mut result = Self::default(); let mut result = Self::default();
// {{{ Songs // {{{ Songs

View file

@ -8,7 +8,7 @@ use num::{Integer, ToPrimitive};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::arcaea::chart::{Difficulty, Jacket, SongCache}; use crate::arcaea::chart::{Difficulty, Jacket, SongCache};
use crate::assets::get_asset_dir; use crate::context::paths::ShimmeringPaths;
use crate::context::Error; use crate::context::Error;
// }}} // }}}
@ -83,9 +83,9 @@ pub struct JacketCache {
} }
// {{{ Read jackets // {{{ 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 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")?; let entries = fs::read_dir(songs_dir).with_context(|| "Couldn't read songs directory")?;
for entry in entries { for entry in entries {
@ -150,8 +150,8 @@ pub fn read_jackets(song_cache: &mut SongCache) -> Result<(), Error> {
impl JacketCache { impl JacketCache {
// {{{ Generate // {{{ Generate
pub fn new() -> Result<Self, Error> { pub fn new(paths: &ShimmeringPaths) -> Result<Self, Error> {
let bytes = fs::read(get_asset_dir().join("songs/recognition_matrix")) let bytes = fs::read(paths.recognition_matrix_path())
.with_context(|| "Could not read jacket recognition matrix")?; .with_context(|| "Could not read jacket recognition matrix")?;
let result = postcard::from_bytes(&bytes)?; let result = postcard::from_bytes(&bytes)?;

View file

@ -1,94 +1,61 @@
// {{{ Imports // {{{ Imports
use std::cell::RefCell; use std::cell::RefCell;
use std::env::var; use std::sync::LazyLock;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::{LazyLock, OnceLock};
use std::thread::LocalKey; use std::thread::LocalKey;
use freetype::{Face, Library}; use freetype::{Face, Library};
use image::{DynamicImage, RgbaImage}; use image::{DynamicImage, RgbaImage};
use crate::arcaea::chart::Difficulty; 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 // {{{ Font helpers
#[inline] pub type Font = Face<&'static [u8]>;
fn get_font(name: &str) -> RefCell<Face> {
let fonts_dir = get_path("SHIMMERING_FONTS_DIR"); macro_rules! get_font {
let face = FREETYPE_LIB.with(|lib| { ($name: literal) => {{
lib.new_face(fonts_dir.join(name), 0) static FONT_CONTENTS: &[u8] =
.unwrap_or_else(|_| panic!("Could not load {} font", name)) include_bytes!(concat!(env!("SHIMMERING_FONT_DIR"), "/", $name));
}); let face = FREETYPE_LIB.with(|lib| {
RefCell::new(face) lib.new_memory_face2(FONT_CONTENTS, 0)
.unwrap_or_else(|_| panic!("Could not load {} font", $name))
});
RefCell::new(face)
}};
} }
#[inline] #[inline]
pub fn with_font<T>( pub fn with_font<T>(
primary: &'static LocalKey<RefCell<Face>>, primary: &'static LocalKey<RefCell<Font>>,
f: impl FnOnce(&mut [&mut Face]) -> T, f: impl FnOnce(&mut [&mut Font]) -> T,
) -> T { ) -> T {
UNI_FONT.with_borrow_mut(|uni| primary.with_borrow_mut(|primary| f(&mut [primary, uni]))) UNI_FONT.with_borrow_mut(|uni| primary.with_borrow_mut(|primary| f(&mut [primary, uni])))
} }
// }}} // }}}
// {{{ Font loading // {{{ Font loading
// TODO: I might want to embed those into the binary 🤔
thread_local! { thread_local! {
pub static FREETYPE_LIB: Library = Library::init().unwrap(); pub static FREETYPE_LIB: Library = Library::init().unwrap();
pub static EXO_FONT: RefCell<Face> = get_font("Exo[wght].ttf"); pub static EXO_FONT: RefCell<Font> = get_font!("Exo[wght].ttf");
pub static GEOSANS_FONT: RefCell<Face> = get_font("GeosansLight.ttf"); pub static GEOSANS_FONT: RefCell<Font> = get_font!("GeosansLight.ttf");
pub static KAZESAWA_FONT: RefCell<Face> = get_font("Kazesawa-Regular.ttf"); pub static KAZESAWA_FONT: RefCell<Font> = get_font!("Kazesawa-Regular.ttf");
pub static KAZESAWA_BOLD_FONT: RefCell<Face> = get_font("Kazesawa-Bold.ttf"); pub static KAZESAWA_BOLD_FONT: RefCell<Font> = get_font!("Kazesawa-Bold.ttf");
pub static UNI_FONT: RefCell<Face> = get_font("unifont.otf"); pub static UNI_FONT: RefCell<Font> = get_font!("unifont.otf");
} }
// }}} // }}}
// {{{ Asset art helpers // {{{ 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 { macro_rules! get_asset {
($name: ident, $path:expr) => { ($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<RgbaImage> = LazyLock::new(move || { pub static $name: LazyLock<RgbaImage> = LazyLock::new(move || {
timed!($path, { static IMAGE_BYTES: &[u8] = include_bytes!(concat!(env!($env_var), "/", $path));
let image = image::open(get_asset_dir().join($path))
.unwrap_or_else(|_| panic!("Could no read asset `{}`", $path)); let image = image::load_from_memory(&IMAGE_BYTES)
let f = $f; .unwrap_or_else(|_| panic!("Could no read asset `{}`", $path));
f(image).into_rgba8()
}) let f = $f;
f(image).into_rgba8()
}); });
}; };
} }
@ -104,22 +71,23 @@ get_asset!(PTT_EMBLEM, "ptt_emblem.png");
get_asset!( get_asset!(
B30_BACKGROUND, B30_BACKGROUND,
"b30_background.jpg", "b30_background.jpg",
"SHIMMERING_PRIVATE_CONFIG_DIR",
|image: DynamicImage| image.blur(7.0) |image: DynamicImage| image.blur(7.0)
); );
pub fn get_difficulty_background(difficulty: Difficulty) -> &'static RgbaImage { pub fn get_difficulty_background(difficulty: Difficulty) -> &'static RgbaImage {
static CELL: OnceLock<[RgbaImage; 5]> = OnceLock::new(); get_asset!(PST_BACKGROUND, "diff_pst.png");
&CELL.get_or_init(|| { get_asset!(PRS_BACKGROUND, "diff_prs.png");
timed!("load_difficulty_background", { get_asset!(FTR_BACKGROUND, "diff_ftr.png");
let assets_dir = get_asset_dir(); get_asset!(ETR_BACKGROUND, "diff_etr.png");
Difficulty::DIFFICULTY_SHORTHANDS.map(|shorthand| { get_asset!(BYD_BACKGROUND, "diff_byd.png");
image::open(assets_dir.join(format!("diff_{}.png", shorthand.to_lowercase())))
.unwrap_or_else(|_| { [
panic!("Could not get background for difficulty {shorthand:?}") &PST_BACKGROUND,
}) &PRS_BACKGROUND,
.into_rgba8() &FTR_BACKGROUND,
}) &ETR_BACKGROUND,
}) &BYD_BACKGROUND,
})[difficulty.to_index()] ][difficulty.to_index()]
} }
// }}} // }}}

View file

@ -7,6 +7,5 @@ pub struct Cli {
#[derive(clap::Subcommand)] #[derive(clap::Subcommand)]
pub enum Command { pub enum Command {
PrepareJackets {},
Analyse(crate::commands::analyse::Args), Analyse(crate::commands::analyse::Args),
} }

View file

@ -13,7 +13,7 @@ pub struct Args {
} }
pub async fn run(args: Args) -> Result<(), Error> { 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; let res = magic_impl(&mut ctx, &args.files).await;
ctx.handle_error(res).await?; ctx.handle_error(res).await?;
Ok(()) Ok(())

View file

@ -1,2 +1 @@
pub mod analyse; pub mod analyse;
pub mod prepare_jackets;

View file

@ -4,9 +4,10 @@ use std::path::PathBuf;
use std::str::FromStr; use std::str::FromStr;
extern crate shimmeringmoon; extern crate shimmeringmoon;
use anyhow::Context;
use poise::CreateReply; use poise::CreateReply;
use shimmeringmoon::assets::get_var;
use shimmeringmoon::commands::discord::mock::ReplyEssence; use shimmeringmoon::commands::discord::mock::ReplyEssence;
use shimmeringmoon::context::paths::get_var;
use shimmeringmoon::context::Error; use shimmeringmoon::context::Error;
use shimmeringmoon::{commands::discord::MessageContext, context::UserContext}; use shimmeringmoon::{commands::discord::MessageContext, context::UserContext};
// }}} // }}}
@ -22,13 +23,13 @@ pub struct CliContext {
} }
impl CliContext { impl CliContext {
pub fn new(data: UserContext) -> Self { pub fn new(data: UserContext) -> anyhow::Result<Self> {
Self { Ok(Self {
data, data,
user_id: get_var("SHIMMERING_DISCORD_USER_ID") user_id: get_var("SHIMMERING_DISCORD_USER_ID")?
.parse() .parse()
.expect("invalid user id"), .with_context(|| "$SHIMMERING_DISCORD_USER_ID contains an invalid user id")?,
} })
} }
} }

View file

@ -10,9 +10,6 @@ mod context;
async fn main() -> Result<(), Error> { async fn main() -> Result<(), Error> {
let cli = Cli::parse(); let cli = Cli::parse();
match cli.command { match cli.command {
Command::PrepareJackets {} => {
commands::prepare_jackets::run()?;
}
Command::Analyse(args) => { Command::Analyse(args) => {
commands::analyse::run(args).await?; commands::analyse::run(args).await?;
} }

View file

@ -54,9 +54,9 @@ async fn main() {
let framework = poise::Framework::builder() let framework = poise::Framework::builder()
.setup(move |ctx, _ready, framework| { .setup(move |ctx, _ready, framework| {
Box::pin(async move { 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?; 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" { if var("SHIMMERING_REGEN_SCORES").unwrap_or_default() == "1" {
timed!("generate_missing_scores", { timed!("generate_missing_scores", {

View file

@ -7,14 +7,14 @@ use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
use shimmeringmoon::arcaea::chart::Difficulty; use shimmeringmoon::arcaea::chart::Difficulty;
use shimmeringmoon::arcaea::play::PlayWithDetails; use shimmeringmoon::arcaea::play::PlayWithDetails;
use shimmeringmoon::arcaea::score::ScoringSystem; use shimmeringmoon::arcaea::score::ScoringSystem;
use shimmeringmoon::assets::get_var; use shimmeringmoon::context::paths::get_var;
use shimmeringmoon::context::Error; use shimmeringmoon::context::Error;
// }}} // }}}
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Error> { async fn main() -> Result<(), Error> {
let server_url = get_var("SHIMMERING_SERVER_URL"); let server_url = get_var("SHIMMERING_SERVER_URL")?;
let client_id = get_var("SHIMMERING_DISCORD_ID"); let client_id = get_var("SHIMMERING_DISCORD_ID")?;
println!("Connecting to discord..."); println!("Connecting to discord...");
let mut ipc = DiscordIpcClient::new(&client_id).map_err(|e| anyhow!("{}", e))?; let mut ipc = DiscordIpcClient::new(&client_id).map_err(|e| anyhow!("{}", e))?;

View file

@ -1,7 +1,7 @@
use context::AppContext; use context::AppContext;
use routes::jacket::get_jacket_image; use routes::jacket::get_jacket_image;
use routes::recent_plays::get_recent_play; use routes::recent_plays::get_recent_play;
use shimmeringmoon::assets::get_var; use shimmeringmoon::context::paths::get_var;
use shimmeringmoon::context::{Error, UserContext}; use shimmeringmoon::context::{Error, UserContext};
mod context; mod context;
@ -10,7 +10,7 @@ mod routes;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Error> { 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() let app = axum::Router::new()
.route("/plays/latest", axum::routing::get(get_recent_play)) .route("/plays/latest", axum::routing::get(get_recent_play))
@ -20,7 +20,7 @@ async fn main() -> Result<(), Error> {
) )
.with_state(AppContext::new(ctx)); .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)) let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{}", port))
.await .await
.unwrap(); .unwrap();

View file

@ -10,11 +10,11 @@ use anyhow::anyhow;
use freetype::bitmap::PixelMode; use freetype::bitmap::PixelMode;
use freetype::face::{KerningMode, LoadFlag}; use freetype::face::{KerningMode, LoadFlag};
use freetype::ffi::{FT_Set_Var_Design_Coordinates, FT_GLYPH_BBOX_PIXELS}; 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 image::{GenericImage, RgbImage, RgbaImage};
use num::traits::Euclid; use num::traits::Euclid;
use crate::assets::FREETYPE_LIB; use crate::assets::{Font, FREETYPE_LIB};
use crate::context::Error; use crate::context::Error;
// }}} // }}}
@ -304,7 +304,7 @@ impl BitmapCanvas {
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
pub fn plan_text_rendering( pub fn plan_text_rendering(
pos: Position, pos: Position,
faces: &mut [&mut Face], faces: &mut [&mut Font],
style: TextStyle, style: TextStyle,
text: &str, text: &str,
) -> Result<(Position, Rect, Vec<(i64, Glyph)>), Error> { ) -> Result<(Position, Rect, Vec<(i64, Glyph)>), Error> {
@ -430,7 +430,7 @@ impl BitmapCanvas {
pub fn text( pub fn text(
&mut self, &mut self,
pos: Position, pos: Position,
faces: &mut [&mut Face], faces: &mut [&mut Font],
style: TextStyle, style: TextStyle,
text: &str, text: &str,
) -> Result<(), Error> { ) -> Result<(), Error> {
@ -770,7 +770,7 @@ impl LayoutDrawer {
&mut self, &mut self,
id: LayoutBoxId, id: LayoutBoxId,
pos: Position, pos: Position,
faces: &mut [&mut Face], faces: &mut [&mut Font],
style: TextStyle, style: TextStyle,
text: &str, text: &str,
) -> Result<(), Error> { ) -> Result<(), Error> {

View file

@ -3,7 +3,7 @@ use num::{FromPrimitive, Rational32};
use crate::arcaea::play::{compute_b30_ptt, get_best_plays}; use crate::arcaea::play::{compute_b30_ptt, get_best_plays};
use crate::arcaea::rating::{rating_as_float, rating_from_fixed, Rating}; 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::recognition::fuzzy_song_name::guess_song_and_chart;
use crate::user::User; use crate::user::User;
@ -20,7 +20,7 @@ use super::discord::MessageContext;
subcommands("expected", "rating"), subcommands("expected", "rating"),
subcommand_required subcommand_required
)] )]
pub async fn calc(_ctx: Context<'_>) -> Result<(), Error> { pub async fn calc(_ctx: PoiseContext<'_>) -> Result<(), Error> {
Ok(()) Ok(())
} }
// }}} // }}}
@ -114,7 +114,7 @@ mod expected_tests {
/// Computes the expected score for a player of some potential on a given chart. /// Computes the expected score for a player of some potential on a given chart.
#[poise::command(prefix_command, slash_command, user_cooldown = 1)] #[poise::command(prefix_command, slash_command, user_cooldown = 1)]
async fn expected( async fn expected(
mut ctx: Context<'_>, mut ctx: PoiseContext<'_>,
#[description = "The potential to compute the expected score for"] ptt: Option<f32>, #[description = "The potential to compute the expected score for"] ptt: Option<f32>,
#[rest] #[rest]
#[description = "Name of chart (difficulty at the end)"] #[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. /// Computes the rating (potential) of a play on a given chart.
#[poise::command(prefix_command, slash_command, user_cooldown = 1)] #[poise::command(prefix_command, slash_command, user_cooldown = 1)]
async fn rating( async fn rating(
mut ctx: Context<'_>, mut ctx: PoiseContext<'_>,
score: u32, score: u32,
#[rest] #[rest]
#[description = "Name of chart (difficulty at the end)"] #[description = "Name of chart (difficulty at the end)"]

View file

@ -3,7 +3,7 @@ use anyhow::anyhow;
use poise::serenity_prelude::{CreateAttachment, CreateEmbed}; use poise::serenity_prelude::{CreateAttachment, CreateEmbed};
use crate::arcaea::{chart::Side, play::Play}; 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::recognition::fuzzy_song_name::guess_song_and_chart;
use crate::user::User; use crate::user::User;
use std::io::Cursor; use std::io::Cursor;
@ -31,7 +31,7 @@ use super::discord::{CreateReplyExtra, MessageContext};
subcommands("info", "best", "plot"), subcommands("info", "best", "plot"),
subcommand_required subcommand_required
)] )]
pub async fn chart(_ctx: Context<'_>) -> Result<(), Error> { pub async fn chart(_ctx: PoiseContext<'_>) -> Result<(), Error> {
Ok(()) Ok(())
} }
// }}} // }}}
@ -136,7 +136,7 @@ mod info_tests {
/// Show a chart given it's name /// Show a chart given it's name
#[poise::command(prefix_command, slash_command, user_cooldown = 1)] #[poise::command(prefix_command, slash_command, user_cooldown = 1)]
async fn info( async fn info(
mut ctx: Context<'_>, mut ctx: PoiseContext<'_>,
#[rest] #[rest]
#[description = "Name of chart (difficulty at the end)"] #[description = "Name of chart (difficulty at the end)"]
name: String, name: String,
@ -249,7 +249,7 @@ mod best_tests {
/// Show the best score on a given chart /// Show the best score on a given chart
#[poise::command(prefix_command, slash_command, user_cooldown = 1)] #[poise::command(prefix_command, slash_command, user_cooldown = 1)]
async fn best( async fn best(
mut ctx: Context<'_>, mut ctx: PoiseContext<'_>,
#[rest] #[rest]
#[description = "Name of chart (difficulty at the end)"] #[description = "Name of chart (difficulty at the end)"]
name: String, name: String,
@ -400,7 +400,7 @@ async fn plot_impl<C: MessageContext>(
/// Show the best score on a given chart /// Show the best score on a given chart
#[poise::command(prefix_command, slash_command, user_cooldown = 10)] #[poise::command(prefix_command, slash_command, user_cooldown = 10)]
async fn plot( async fn plot(
mut ctx: Context<'_>, mut ctx: PoiseContext<'_>,
scoring_system: Option<ScoringSystem>, scoring_system: Option<ScoringSystem>,
#[rest] #[rest]
#[description = "Name of chart (difficulty at the end)"] #[description = "Name of chart (difficulty at the end)"]

View file

@ -1,17 +1,17 @@
use crate::context::{Context, Error}; use crate::context::{Error, PoiseContext};
pub mod calc;
pub mod chart; pub mod chart;
pub mod discord; pub mod discord;
pub mod score; pub mod score;
pub mod stats; pub mod stats;
pub mod utils; pub mod utils;
pub mod calc;
// {{{ Help // {{{ Help
/// Show this help menu /// Show this help menu
#[poise::command(prefix_command, slash_command, subcommands("scoring", "scoringz"))] #[poise::command(prefix_command, slash_command, subcommands("scoring", "scoringz"))]
pub async fn help( pub async fn help(
ctx: Context<'_>, ctx: PoiseContext<'_>,
#[description = "Specific command to show help about"] #[description = "Specific command to show help about"]
#[autocomplete = "poise::builtins::autocomplete_command"] #[autocomplete = "poise::builtins::autocomplete_command"]
#[rest] #[rest]
@ -33,7 +33,7 @@ pub async fn help(
// {{{ Scoring help // {{{ Scoring help
/// Explains the different scoring systems /// Explains the different scoring systems
#[poise::command(prefix_command, slash_command)] #[poise::command(prefix_command, slash_command)]
async fn scoring(ctx: Context<'_>) -> Result<(), Error> { async fn scoring(ctx: PoiseContext<'_>) -> Result<(), Error> {
static CONTENT: &str = " static CONTENT: &str = "
## 1. Standard scoring (`standard`): ## 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. 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 // {{{ Scoring gen-z help
/// Explains the different scoring systems using gen-z slang /// Explains the different scoring systems using gen-z slang
#[poise::command(prefix_command, slash_command)] #[poise::command(prefix_command, slash_command)]
async fn scoringz(ctx: Context<'_>) -> Result<(), Error> { async fn scoringz(ctx: PoiseContext<'_>) -> Result<(), Error> {
static CONTENT: &str = " static CONTENT: &str = "
## 1. Standard scoring (`standard`): ## 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. 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.

View file

@ -1,7 +1,7 @@
// {{{ Imports // {{{ Imports
use crate::arcaea::play::{CreatePlay, Play}; use crate::arcaea::play::{CreatePlay, Play};
use crate::arcaea::score::Score; 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::recognition::recognize::{ImageAnalyzer, ScoreKind};
use crate::user::User; use crate::user::User;
use crate::{get_user_error, timed, try_block}; use crate::{get_user_error, timed, try_block};
@ -20,7 +20,7 @@ use super::discord::{CreateReplyExtra, MessageContext};
subcommands("magic", "delete", "show"), subcommands("magic", "delete", "show"),
subcommand_required subcommand_required
)] )]
pub async fn score(_ctx: Context<'_>) -> Result<(), Error> { pub async fn score(_ctx: PoiseContext<'_>) -> Result<(), Error> {
Ok(()) Ok(())
} }
// }}} // }}}
@ -55,7 +55,6 @@ pub async fn magic_impl<C: MessageContext>(
analyzer.read_score_kind(ctx.data(), &grayscale_image)? analyzer.read_score_kind(ctx.data(), &grayscale_image)?
}); });
// Do not use `ocr_image` because this reads the colors
let difficulty = timed!("read_difficulty", { let difficulty = timed!("read_difficulty", {
analyzer.read_difficulty(ctx.data(), &image, &grayscale_image, kind)? analyzer.read_difficulty(ctx.data(), &image, &grayscale_image, kind)?
}); });
@ -104,7 +103,6 @@ pub async fn magic_impl<C: MessageContext>(
// }}} // }}}
// }}} // }}}
// {{{ Deliver embed // {{{ Deliver embed
let (embed, attachment) = timed!("to embed", { let (embed, attachment) = timed!("to embed", {
play.to_embed(ctx.data(), &user, song, chart, i, None)? play.to_embed(ctx.data(), &user, song, chart, i, None)?
}); });
@ -193,7 +191,7 @@ mod magic_tests {
/// Identify scores from attached images. /// Identify scores from attached images.
#[poise::command(prefix_command, slash_command)] #[poise::command(prefix_command, slash_command)]
pub async fn magic( pub async fn magic(
mut ctx: Context<'_>, mut ctx: PoiseContext<'_>,
#[description = "Images containing scores"] files: Vec<serenity::Attachment>, #[description = "Images containing scores"] files: Vec<serenity::Attachment>,
) -> Result<(), Error> { ) -> Result<(), Error> {
let res = magic_impl(&mut ctx, &files).await; let res = magic_impl(&mut ctx, &files).await;
@ -326,7 +324,7 @@ mod show_tests {
/// Show scores given their IDs. /// Show scores given their IDs.
#[poise::command(prefix_command, slash_command)] #[poise::command(prefix_command, slash_command)]
pub async fn show( pub async fn show(
mut ctx: Context<'_>, mut ctx: PoiseContext<'_>,
#[description = "Ids of score to show"] ids: Vec<u32>, #[description = "Ids of score to show"] ids: Vec<u32>,
) -> Result<(), Error> { ) -> Result<(), Error> {
let res = show_impl(&mut ctx, &ids).await; let res = show_impl(&mut ctx, &ids).await;
@ -451,7 +449,7 @@ mod delete_tests {
/// Delete scores, given their IDs. /// Delete scores, given their IDs.
#[poise::command(prefix_command, slash_command)] #[poise::command(prefix_command, slash_command)]
pub async fn delete( pub async fn delete(
mut ctx: Context<'_>, mut ctx: PoiseContext<'_>,
#[description = "Id of score to delete"] ids: Vec<u32>, #[description = "Id of score to delete"] ids: Vec<u32>,
) -> Result<(), Error> { ) -> Result<(), Error> {
let res = delete_impl(&mut ctx, &ids).await; let res = delete_impl(&mut ctx, &ids).await;

View file

@ -18,7 +18,7 @@ use crate::assets::{
TOP_BACKGROUND, TOP_BACKGROUND,
}; };
use crate::bitmap::{Align, BitmapCanvas, Color, LayoutDrawer, LayoutManager, Rect}; 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::logs::debug_image_log;
use crate::user::User; use crate::user::User;
@ -33,7 +33,7 @@ use super::discord::MessageContext;
subcommands("meta", "b30", "bany"), subcommands("meta", "b30", "bany"),
subcommand_required subcommand_required
)] )]
pub async fn stats(_ctx: Context<'_>) -> Result<(), Error> { pub async fn stats(_ctx: PoiseContext<'_>) -> Result<(), Error> {
Ok(()) Ok(())
} }
// }}} // }}}
@ -429,7 +429,10 @@ pub async fn b30_impl<C: MessageContext>(
// {{{ Discord wrapper // {{{ Discord wrapper
/// Show the 30 best scores /// Show the 30 best scores
#[poise::command(prefix_command, slash_command, user_cooldown = 30)] #[poise::command(prefix_command, slash_command, user_cooldown = 30)]
pub async fn b30(mut ctx: Context<'_>, scoring_system: Option<ScoringSystem>) -> Result<(), Error> { pub async fn b30(
mut ctx: PoiseContext<'_>,
scoring_system: Option<ScoringSystem>,
) -> Result<(), Error> {
let res = b30_impl(&mut ctx, scoring_system).await; let res = b30_impl(&mut ctx, scoring_system).await;
ctx.handle_error(res).await?; ctx.handle_error(res).await?;
Ok(()) Ok(())
@ -461,7 +464,7 @@ async fn bany_impl<C: MessageContext>(
// {{{ Discord wrapper // {{{ Discord wrapper
#[poise::command(prefix_command, slash_command, hide_in_help, global_cooldown = 5)] #[poise::command(prefix_command, slash_command, hide_in_help, global_cooldown = 5)]
pub async fn bany( pub async fn bany(
mut ctx: Context<'_>, mut ctx: PoiseContext<'_>,
scoring_system: Option<ScoringSystem>, scoring_system: Option<ScoringSystem>,
width: u32, width: u32,
height: u32, height: u32,
@ -537,7 +540,7 @@ async fn meta_impl<C: MessageContext>(ctx: &mut C) -> Result<(), TaggedError> {
// {{{ Discord wrapper // {{{ Discord wrapper
/// Show stats about the bot itself. /// Show stats about the bot itself.
#[poise::command(prefix_command, slash_command, user_cooldown = 1)] #[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; let res = meta_impl(&mut ctx).await;
ctx.handle_error(res).await?; ctx.handle_error(res).await?;

68
src/context/db.rs Normal file
View file

@ -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<SqliteConnectionManager>;
pub fn connect_db(paths: &ShimmeringPaths) -> anyhow::Result<SqlitePool> {
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<Migrations> = 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.")
}

24
src/context/hash.rs Normal file
View file

@ -0,0 +1,24 @@
use sha2::{Digest, Sha256};
pub fn hash_files(path: &std::path::Path) -> anyhow::Result<String> {
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)
}

View file

@ -1,22 +1,23 @@
// {{{ Imports // {{{ Imports
use include_dir::{include_dir, Dir}; use db::{connect_db, SqlitePool};
use r2d2::Pool; use std::ops::Deref;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite_migration::Migrations;
use std::fs;
use std::path::Path;
use std::sync::LazyLock;
use crate::arcaea::jacket::read_jackets; use crate::arcaea::jacket::read_jackets;
use crate::arcaea::{chart::SongCache, jacket::JacketCache}; 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::recognition::{hyperglass::CharMeasurements, ui::UIMeasurements};
use crate::timed; use crate::timed;
// }}} // }}}
pub mod db;
mod hash;
pub mod paths;
mod process_jackets;
// {{{ Common types // {{{ Common types
pub type Error = anyhow::Error; 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 // {{{ Error handling
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@ -64,37 +65,17 @@ impl TagError for Error {
} }
} }
// }}} // }}}
// {{{ DB connection
pub type DbConnection = r2d2::Pool<SqliteConnectionManager>;
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<Migrations> = 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 // {{{ UserContext
/// Custom user data passed to all command functions /// Custom user data passed to all command functions
#[derive(Clone)] #[derive(Clone)]
pub struct UserContext { pub struct UserContext {
pub db: DbConnection, pub db: SqlitePool,
pub song_cache: SongCache, pub song_cache: SongCache,
pub jacket_cache: JacketCache, pub jacket_cache: JacketCache,
pub ui_measurements: UIMeasurements, pub ui_measurements: UIMeasurements,
pub paths: ShimmeringPaths,
pub geosans_measurements: CharMeasurements, pub geosans_measurements: CharMeasurements,
pub exo_measurements: CharMeasurements, pub exo_measurements: CharMeasurements,
// TODO: do we really need both after I've fixed the bug in the ocr code? // 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 { impl UserContext {
#[inline] #[inline]
pub async fn new() -> Result<Self, Error> { pub fn new() -> Result<Self, Error> {
timed!("create_context", { 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 ui_measurements = UIMeasurements::read()?;
let jacket_cache = JacketCache::new()?; let jacket_cache = JacketCache::new(&paths)?;
timed!("read_jackets", {
read_jackets(&mut song_cache)?; read_jackets(&paths, &mut song_cache)?;
});
// {{{ Font measurements // {{{ Font measurements
static WHITELIST: &str = "0123456789'abcdefghklmnopqrstuvwxyzABCDEFGHIJKLMNOPRSTUVWXYZ"; static WHITELIST: &str = "0123456789'abcdefghklmnopqrstuvwxyzABCDEFGHIJKLMNOPRSTUVWXYZ";
@ -130,6 +111,7 @@ impl UserContext {
Ok(Self { Ok(Self {
db, db,
paths,
song_cache, song_cache,
jacket_cache, jacket_cache,
ui_measurements, ui_measurements,
@ -145,24 +127,20 @@ impl UserContext {
// {{{ Testing helpers // {{{ Testing helpers
#[cfg(test)] #[cfg(test)]
pub mod testing { pub mod testing {
use std::cell::OnceCell;
use tempfile::TempDir; use tempfile::TempDir;
use super::*;
use crate::commands::discord::mock::MockContext; use crate::commands::discord::mock::MockContext;
use super::*; pub fn get_shared_context() -> &'static UserContext {
static CELL: OnceCell<UserContext> = OnceCell::new();
pub async fn get_shared_context() -> &'static UserContext { CELL.get_or_init(|| UserContext::new().unwrap())
static CELL: tokio::sync::OnceCell<UserContext> = tokio::sync::OnceCell::const_new();
CELL.get_or_init(|| async move {
// env::set_var("SHIMMERING_DATA_DIR", "")
UserContext::new().await.unwrap()
})
.await
} }
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") let out = std::process::Command::new("scripts/copy-chart-info.sh")
.arg(get_data_dir()) .arg(paths.data_dir())
.arg(to) .arg(to)
.output() .output()
.expect("Could not run sh chart info copy script"); .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> { pub fn get_mock_context() -> Result<(MockContext, TempDir), Error> {
let mut data = (*get_shared_context().await).clone(); let mut data = (*get_shared_context()).clone();
let dir = tempfile::tempdir()?; let dir = tempfile::tempdir()?;
data.db = connect_db(dir.path()); 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); let ctx = MockContext::new(data);
Ok((ctx, dir)) Ok((ctx, dir))
@ -202,7 +180,7 @@ pub mod testing {
($test_path:expr, $f:expr) => {{ ($test_path:expr, $f:expr) => {{
use std::str::FromStr; 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); let res = $crate::user::User::create_from_context(&ctx);
ctx.handle_error(res).await?; ctx.handle_error(res).await?;

94
src/context/paths.rs Normal file
View file

@ -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<String> {
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<PathBuf> {
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<Self> {
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(())
}

View file

@ -1,49 +1,56 @@
// {{{ Imports // {{{ Imports
use std::fmt::Write;
use std::fs; use std::fs;
use std::io::{stdout, Write}; use std::io::{stdout, Write as IOWrite};
use anyhow::{anyhow, bail, Context}; use anyhow::{anyhow, bail, Context};
use faer::Mat; use faer::Mat;
use image::imageops::FilterType; use image::imageops::FilterType;
use shimmeringmoon::arcaea::chart::{Difficulty, SongCache}; use crate::arcaea::chart::{Difficulty, SongCache};
use shimmeringmoon::arcaea::jacket::{ use crate::arcaea::jacket::{
image_to_vec, read_jackets, JacketCache, BITMAP_IMAGE_SIZE, IMAGE_VEC_DIM, image_to_vec, read_jackets, JacketCache, BITMAP_IMAGE_SIZE, IMAGE_VEC_DIM,
JACKET_RECOGNITITION_DIMENSIONS, JACKET_RECOGNITITION_DIMENSIONS,
}; };
use shimmeringmoon::assets::{get_asset_dir, get_data_dir}; use crate::context::paths::create_empty_directory;
use shimmeringmoon::context::{connect_db, Error}; use crate::recognition::fuzzy_song_name::guess_chart_name;
use shimmeringmoon::recognition::fuzzy_song_name::guess_chart_name;
use super::paths::ShimmeringPaths;
// }}} // }}}
/// Hacky function which clears the current line of the standard output. /// Runs the entire jacket processing pipeline:
#[inline] /// 1. Read all the jackets in the input directory, and infer
fn clear_line() { /// what song/chart they belong to.
print!("\r \r"); /// 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.
pub fn run() -> Result<(), Error> { /// 4. Ensure no charts are missing a jacket.
let db = connect_db(&get_data_dir()); /// 5. Create a matrix we can use for image recognition.
let mut song_cache = SongCache::new(&db)?; /// 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_vector_ids = vec![];
let mut jacket_vectors = vec![]; let mut jacket_vectors = vec![];
// {{{ Prepare directories // Contains a dir_name -> song_name map that's useful when debugging
let songs_dir = get_asset_dir().join("songs"); // name recognition. This will get written to disk in case a missing
let raw_songs_dir = songs_dir.join("raw"); // jacket is detected.
let mut debug_name_mapping = String::new();
let by_id_dir = songs_dir.join("by_id"); // {{{ Prepare directories
if by_id_dir.exists() { let jackets_dir = paths.jackets_path();
fs::remove_dir_all(&by_id_dir).with_context(|| "Could not remove `by_id` dir")?; let raw_jackets_dir = paths.raw_jackets_path();
}
fs::create_dir_all(&by_id_dir).with_context(|| "Could not create `by_id` dir")?; create_empty_directory(&jackets_dir)?;
// }}} // }}}
// {{{ Traverse raw songs directory // {{{ Traverse raw songs directory
let entries = fs::read_dir(&raw_songs_dir) let entries = fs::read_dir(&raw_jackets_dir)
.with_context(|| "Couldn't read songs directory")? .with_context(|| "Could not list contents of $SHIMMERING_PRIVATE_CONFIG/jackets")?
.collect::<Result<Vec<_>, _>>() .collect::<Result<Vec<_>, _>>()
.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() { for (i, dir) in entries.iter().enumerate() {
let raw_dir_name = dir.file_name(); let raw_dir_name = dir.file_name();
@ -54,7 +61,7 @@ pub fn run() -> Result<(), Error> {
clear_line(); clear_line();
} }
print!("{}/{}: {dir_name}", i, entries.len()); print!(" 🕒 {}/{}: {dir_name}", i, entries.len());
stdout().flush()?; stdout().flush()?;
// }}} // }}}
@ -84,31 +91,15 @@ pub fn run() -> Result<(), Error> {
_ => bail!("Unknown jacket suffix {}", name), _ => bail!("Unknown jacket suffix {}", name),
}; };
// Sometimes it's useful to distinguish between separate (but related) let (song, _) = guess_chart_name(dir_name, &song_cache, difficulty, true)
// 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)
.with_context(|| format!("Could not recognise chart name from '{dir_name}'"))?; .with_context(|| format!("Could not recognise chart name from '{dir_name}'"))?;
// {{{ Set up `out_dir` paths writeln!(debug_name_mapping, "{dir_name} -> {}", song.title)?;
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
)
})?;
}
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 { let difficulty_string = if let Some(difficulty) = difficulty {
&Difficulty::DIFFICULTY_SHORTHANDS[difficulty.to_index()].to_lowercase() &Difficulty::DIFFICULTY_SHORTHANDS[difficulty.to_index()].to_lowercase()
@ -119,6 +110,7 @@ pub fn run() -> Result<(), Error> {
let contents: &'static _ = fs::read(file.path()) let contents: &'static _ = fs::read(file.path())
.with_context(|| format!("Could not read image for file {:?}", file.path()))? .with_context(|| format!("Could not read image for file {:?}", file.path()))?
.leak(); .leak();
let image = image::load_from_memory(contents)?; let image = image::load_from_memory(contents)?;
let small_image = let small_image =
image.resize(BITMAP_IMAGE_SIZE, BITMAP_IMAGE_SIZE, FilterType::Gaussian); image.resize(BITMAP_IMAGE_SIZE, BITMAP_IMAGE_SIZE, FilterType::Gaussian);
@ -153,23 +145,26 @@ pub fn run() -> Result<(), Error> {
// }}} // }}}
clear_line(); clear_line();
println!("Successfully processed jackets"); println!("Successfully processed jackets");
read_jackets(&mut song_cache)?; read_jackets(paths, &mut song_cache)?;
println!("Successfully read jackets"); println!("Successfully read processed jackets");
// {{{ Warn on missing jackets // {{{ Error out on missing jackets
for chart in song_cache.charts() { for chart in song_cache.charts() {
if chart.cached_jacket.is_none() { if chart.cached_jacket.is_none() {
println!( let out_path = paths.log_dir().join("name_mapping.txt");
"No jacket found for '{} [{:?}]'", 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, song_cache.lookup_song(chart.song_id)?.song,
chart.difficulty chart.difficulty
) )
} }
} }
println!("No missing jackets detected"); println!("No missing jackets detected");
// }}} // }}}
// {{{ Compute jacket vec matrix // {{{ Compute jacket vec matrix
let mut jacket_matrix: Mat<f32> = Mat::zeros(IMAGE_VEC_DIM, jacket_vectors.len()); let mut jacket_matrix: Mat<f32> = Mat::zeros(IMAGE_VEC_DIM, jacket_vectors.len());
@ -206,7 +201,7 @@ pub fn run() -> Result<(), Error> {
clear_line(); clear_line();
} }
print!("{}/{}: {song}", i, chart_count); print!(" {}/{}: {song}", i, chart_count);
if i % 5 == 0 { if i % 5 == 0 {
stdout().flush()?; stdout().flush()?;
@ -233,17 +228,23 @@ pub fn run() -> Result<(), Error> {
// }}} // }}}
clear_line(); clear_line();
println!("Successfully tested jacket recognition"); println!("Successfully tested jacket recognition");
// {{{ Save recognition matrix to disk // {{{ Save recognition matrix to disk
{ {
println!("Encoded {} images", jacket_vectors.len()); println!("Encoded {} images", jacket_vectors.len());
let bytes = postcard::to_allocvec(&jacket_cache) let bytes = postcard::to_allocvec(&jacket_cache)
.with_context(|| "Coult not encode jacket matrix")?; .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")?; .with_context(|| "Could not write jacket matrix")?;
} }
// }}} // }}}
Ok(()) Ok(())
} }
/// Hacky function which "clears" the current line of the standard output.
#[inline]
fn clear_line() {
print!("\r \r");
}

View file

@ -9,10 +9,10 @@ pub mod assets;
pub mod bitmap; pub mod bitmap;
pub mod commands; pub mod commands;
pub mod context; pub mod context;
pub mod levenshtein; mod levenshtein;
pub mod logs; pub mod logs;
pub mod recognition; pub mod recognition;
pub mod time; pub mod time;
pub mod transform; pub mod transform;
pub mod user; pub mod user;
pub mod utils; mod utils;

View file

@ -10,7 +10,7 @@ use std::{env, ops::Deref, path::PathBuf, sync::OnceLock, time::Instant};
use image::{DynamicImage, EncodableLayout, ImageBuffer, PixelWithColorType}; use image::{DynamicImage, EncodableLayout, ImageBuffer, PixelWithColorType};
use crate::assets::get_path; use crate::context::paths::get_env_dir_path;
#[inline] #[inline]
fn should_save_debug_images() -> bool { fn should_save_debug_images() -> bool {
@ -21,7 +21,7 @@ fn should_save_debug_images() -> bool {
#[inline] #[inline]
fn get_log_dir() -> PathBuf { fn get_log_dir() -> PathBuf {
get_path("SHIMMERING_LOG_DIR") get_env_dir_path("SHIMMERING_LOG_DIR").unwrap()
} }
#[inline] #[inline]

View file

@ -24,12 +24,12 @@
//! startup using my very own bitmap rendering module (`crate::bitmap`). //! startup using my very own bitmap rendering module (`crate::bitmap`).
// {{{ Imports // {{{ Imports
use anyhow::{anyhow, bail}; use anyhow::{anyhow, bail};
use freetype::Face;
use image::{DynamicImage, ImageBuffer, Luma}; use image::{DynamicImage, ImageBuffer, Luma};
use imageproc::contrast::{threshold, ThresholdType}; use imageproc::contrast::{threshold, ThresholdType};
use imageproc::region_labelling::{connected_components, Connectivity}; use imageproc::region_labelling::{connected_components, Connectivity};
use num::traits::Euclid; use num::traits::Euclid;
use crate::assets::Font;
use crate::bitmap::{Align, BitmapCanvas, Color, TextStyle}; use crate::bitmap::{Align, BitmapCanvas, Color, TextStyle};
use crate::context::Error; use crate::context::Error;
use crate::logs::{debug_image_buffer_log, debug_image_log}; use crate::logs::{debug_image_buffer_log, debug_image_log};
@ -235,7 +235,7 @@ pub struct CharMeasurements {
impl CharMeasurements { impl CharMeasurements {
// {{{ Creation // {{{ Creation
pub fn from_text(face: &mut Face, string: &str, weight: Option<u32>) -> Result<Self, Error> { pub fn from_text(face: &mut Font, string: &str, weight: Option<u32>) -> Result<Self, Error> {
// These are bad estimates lol // These are bad estimates lol
let style = TextStyle { let style = TextStyle {
stroke: None, stroke: None,

View file

@ -1,10 +1,7 @@
// {{{ Imports // {{{ Imports
use std::fs;
use anyhow::anyhow; use anyhow::anyhow;
use image::GenericImage; use image::GenericImage;
use crate::assets::get_config_dir;
use crate::bitmap::Rect; use crate::bitmap::Rect;
use crate::context::Error; use crate::context::Error;
// }}} // }}}
@ -103,11 +100,10 @@ impl UIMeasurements {
let mut measurements = Vec::new(); let mut measurements = Vec::new();
let mut measurement = UIMeasurement::default(); let mut measurement = UIMeasurement::default();
let path = get_config_dir().join("ui.txt"); const CONTENTS: &str = include_str!(concat!(env!("SHIMMERING_CONFIG_DIR"), "/ui.txt"));
let contents = fs::read_to_string(path)?;
// {{{ Parse measurement file // {{{ 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); let i = i % (UI_RECT_COUNT + 2);
if i == 0 { if i == 0 {
for (j, str) in line.split_whitespace().enumerate().take(2) { 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 }) Ok(Self { measurements })
} }
// }}} // }}}

View file

@ -5,7 +5,7 @@ macro_rules! timed {
let start = Instant::now(); let start = Instant::now();
let result = { $code }; // Execute the code block let result = { $code }; // Execute the code block
let duration = start.elapsed(); let duration = start.elapsed();
println!("{}: {:?}", $label, duration); println!("📊 {}: {:?}", $label, duration);
result result
}}; }};
} }