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/logs
shimmering/assets/fonts
shimmering/assets/songs*
shimmering/assets/b30_background.*
shimmering/private_config
target
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"
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",

View file

@ -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

View file

@ -69,7 +69,7 @@
];
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
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(|_| {

View file

@ -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<Self, Error> {
let conn = conn.get()?;
pub fn new(conn: &rusqlite::Connection) -> Result<Self, Error> {
let mut result = Self::default();
// {{{ Songs

View file

@ -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<Self, Error> {
let bytes = fs::read(get_asset_dir().join("songs/recognition_matrix"))
pub fn new(paths: &ShimmeringPaths) -> Result<Self, Error> {
let bytes = fs::read(paths.recognition_matrix_path())
.with_context(|| "Could not read jacket recognition matrix")?;
let result = postcard::from_bytes(&bytes)?;

View file

@ -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<Face> {
let fonts_dir = get_path("SHIMMERING_FONTS_DIR");
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_face(fonts_dir.join(name), 0)
.unwrap_or_else(|_| panic!("Could not load {} font", name))
lib.new_memory_face2(FONT_CONTENTS, 0)
.unwrap_or_else(|_| panic!("Could not load {} font", $name))
});
RefCell::new(face)
}};
}
#[inline]
pub fn with_font<T>(
primary: &'static LocalKey<RefCell<Face>>,
f: impl FnOnce(&mut [&mut Face]) -> T,
primary: &'static LocalKey<RefCell<Font>>,
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<Face> = get_font("Exo[wght].ttf");
pub static GEOSANS_FONT: RefCell<Face> = get_font("GeosansLight.ttf");
pub static KAZESAWA_FONT: RefCell<Face> = get_font("Kazesawa-Regular.ttf");
pub static KAZESAWA_BOLD_FONT: RefCell<Face> = get_font("Kazesawa-Bold.ttf");
pub static UNI_FONT: RefCell<Face> = get_font("unifont.otf");
pub static EXO_FONT: RefCell<Font> = get_font!("Exo[wght].ttf");
pub static GEOSANS_FONT: RefCell<Font> = get_font!("GeosansLight.ttf");
pub static KAZESAWA_FONT: RefCell<Font> = get_font!("Kazesawa-Regular.ttf");
pub static KAZESAWA_BOLD_FONT: RefCell<Font> = get_font!("Kazesawa-Bold.ttf");
pub static UNI_FONT: RefCell<Font> = 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<RgbaImage> = LazyLock::new(move || {
timed!($path, {
let image = image::open(get_asset_dir().join($path))
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()]
}
// }}}

View file

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

View file

@ -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(())

View file

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

View file

@ -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<Self> {
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")?,
})
}
}

View file

@ -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?;
}

View file

@ -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", {

View file

@ -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))?;

View file

@ -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();

View file

@ -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> {

View file

@ -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<f32>,
#[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)"]

View file

@ -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<C: MessageContext>(
/// 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<ScoringSystem>,
#[rest]
#[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 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.

View file

@ -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<C: MessageContext>(
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<C: MessageContext>(
// }}}
// }}}
// {{{ 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<serenity::Attachment>,
) -> 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<u32>,
) -> 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<u32>,
) -> Result<(), Error> {
let res = delete_impl(&mut ctx, &ids).await;

View file

@ -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<C: MessageContext>(
// {{{ 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<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;
ctx.handle_error(res).await?;
Ok(())
@ -461,7 +464,7 @@ async fn bany_impl<C: MessageContext>(
// {{{ 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<ScoringSystem>,
width: u32,
height: u32,
@ -537,7 +540,7 @@ async fn meta_impl<C: MessageContext>(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?;

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
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<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
/// 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<Self, Error> {
pub fn new() -> Result<Self, Error> {
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<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 get_shared_context() -> &'static UserContext {
static CELL: OnceCell<UserContext> = 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?;

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
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::<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() {
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<f32> = 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");
}

View file

@ -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;

View file

@ -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]

View file

@ -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<u32>) -> Result<Self, Error> {
pub fn from_text(face: &mut Font, string: &str, weight: Option<u32>) -> Result<Self, Error> {
// These are bad estimates lol
let style = TextStyle {
stroke: None,

View file

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

View file

@ -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
}};
}