├── .gitignore ├── .pre-commit-config.yaml ├── README.md ├── app ├── __init__.py ├── gazo.py ├── generation │ ├── __init__.py │ ├── canvas.py │ ├── common │ │ ├── __init__.py │ │ └── vector.py │ ├── styles │ │ ├── __init__.py │ │ ├── akatsuki │ │ │ └── __init__.py │ │ └── default │ │ │ └── __init__.py │ └── text │ │ ├── __init__.py │ │ └── text.py ├── objects │ ├── api.py │ ├── beatmap.py │ └── replay.py ├── utils.py └── version.py ├── build.py ├── main.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # Project crap 156 | *.osr 157 | *.png 158 | apikey.txt 159 | .cache/* 160 | .vscode/* 161 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v3.2.0 6 | hooks: 7 | - id: check-added-large-files 8 | args: ["--maxkb=1024"] 9 | - id: check-ast 10 | - id: check-builtin-literals 11 | - id: check-yaml 12 | - id: debug-statements 13 | - id: end-of-file-fixer 14 | - id: trailing-whitespace 15 | 16 | - repo: https://github.com/psf/black 17 | rev: 23.12.1 18 | hooks: 19 | - id: black 20 | 21 | - repo: https://github.com/asottile/pyupgrade 22 | rev: v3.15.0 23 | hooks: 24 | - id: pyupgrade 25 | args: [--py311-plus, --keep-runtime-typing] 26 | 27 | - repo: https://github.com/asottile/reorder-python-imports 28 | rev: v3.12.0 29 | hooks: 30 | - id: reorder-python-imports 31 | args: [--py311-plus, --add-import, "from __future__ import annotations"] 32 | 33 | - repo: https://github.com/asottile/add-trailing-comma 34 | rev: v3.1.0 35 | hooks: 36 | - id: add-trailing-comma 37 | 38 | - repo: https://github.com/asottile/blacken-docs 39 | rev: 1.16.0 40 | hooks: 41 | - id: blacken-docs 42 | 43 | default_language_version: 44 | python: python3.13 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # osr2png - rewrite³ 2 | 3 | [![GitHub release](https://img.shields.io/github/release/xjunko/osr2png.svg?style=for-the-badge&logo=github)](https://github.com/xjunko/osr2png/releases/latest) 4 | 5 | osr2png is a CLI thumbnail generator for osu! maps. 6 | 7 | as I am very lazy and only update this thing few times a year, lots of stuff gonna break. if that happens please file an issue. 8 | 9 | ## Styles 10 | 11 | ## Style 1 12 | ![image](https://github.com/xjunko/osr2png/assets/44401509/98f06ad3-edf7-4998-a853-c4ed24941af3) 13 | ![image](https://github.com/xjunko/osr2png/assets/44401509/463729ef-d474-445a-93b1-d08824727f59) 14 | 15 | ## Style 2 16 | ![image](https://github.com/xjunko/osr2png/assets/44401509/d6066692-1c27-4356-b7f9-58b19b4b5e20) 17 | ![image](https://github.com/xjunko/osr2png/assets/44401509/8b548487-4ccd-4ba4-b7b4-10e700189878) 18 | 19 | 20 | ## Running 21 | 22 | Latest binaries for Linux/Windows can be downloaded from [here](https://github.com/xjunko/osr2png/releases/latest). 23 | 24 | Simply unpack the file somewhere and run it with your terminal. 25 | 26 | ##### Linux / Powershell 27 | 28 | ```bash 29 | ./osr2png 30 | ``` 31 | 32 | ## Run arguments 33 | 34 | ```txt 35 | usage: main.py [-h] [-v] [-r REPLAY] [-b BEATMAP] [-m MESSAGE] [-s STYLE] [-width WIDTH] [-height HEIGHT] [-dim BACKGROUND_DIM] [-blur BACKGROUND_BLUR] [-border BACKGROUND_BORDER] 36 | 37 | An open-source osu! thumbnail generator for lazy circle clickers. 38 | 39 | options: 40 | -h, --help show this help message and exit 41 | -v, --version show program's version number and exit 42 | -r REPLAY, --replay REPLAY 43 | [Optional] The path of the .osr file 44 | -b BEATMAP, --beatmap BEATMAP 45 | [Optional] The path of the .osu file, if using a custom beatmap. 46 | -m MESSAGE, --message MESSAGE 47 | [Optional] The extra text at the bottom 48 | -s STYLE, --style STYLE 49 | Style of Image, [1: default 2: akatsuki] 50 | -width WIDTH, --width WIDTH 51 | [Optional] The width of the image. 52 | -height HEIGHT, --height HEIGHT 53 | [Optional] The width of the image. 54 | -dim BACKGROUND_DIM, --background-dim BACKGROUND_DIM 55 | [Optional] The dim of beatmap background. 56 | -blur BACKGROUND_BLUR, --background-blur BACKGROUND_BLUR 57 | [Optional] The blur of beatmap background. 58 | -border BACKGROUND_BORDER, --background-border BACKGROUND_BORDER 59 | [Optional] The border of beatmap background's dim. 60 | ``` 61 | 62 | Examples: 63 | 64 | ``` 65 | ./osr2png -r replay.osr 66 | 67 | ./osr2png -r replay.osr -b beatmap_file.osu 68 | 69 | ./osr2png -r replay.osr -dim 0.5 -border 50 -blur 15 70 | 71 | ./osr2png -r replay.osr -m "FINALLY FCED" 72 | ``` 73 | 74 | ## Credits 75 | 76 | - [rosu-pp](https://github.com/MaxOhn/rosu-pp): The PP calculator used in this program. 77 | - [kitsu.moe](https://kitsu.moe/): The mirror that is used for getting the beatmap 78 | data. 79 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from . import gazo 4 | from . import generation 5 | from . import utils 6 | from . import version 7 | -------------------------------------------------------------------------------- /app/gazo.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from pathlib import Path 5 | from typing import Any 6 | from typing import Optional 7 | 8 | from rosu_pp_py import PerformanceAttributes 9 | 10 | from app.generation.canvas import Canvas 11 | from app.generation.canvas import CanvasSettings 12 | from app.generation.canvas import CanvasStyle 13 | from app.generation.canvas import Vector2 14 | from app.objects.beatmap import Beatmap 15 | from app.objects.replay import ReplayInfo 16 | 17 | # 18 | OUTPUT_FOLDER: Path = Path.cwd() / "outputs" 19 | OUTPUT_FOLDER.mkdir(exist_ok=True) 20 | 21 | # Common 22 | DEFAULT_FILENAME_FORMAT: str = "[{name}] - ({artist} - {title} [{diff}])" 23 | FILENAME_INVALID_REGEX: re.Pattern = re.compile(r'[<>:"/\\|?*]') 24 | 25 | 26 | class Replay2Picture: 27 | def __init__(self) -> None: 28 | self.replay: ReplayInfo 29 | self.beatmap: Beatmap 30 | self.info: PerformanceAttributes 31 | 32 | # rosu-pp doesn't yet support csr so this will be vastly different from live pp values 33 | def calculate(self) -> None: 34 | print("[Replay2Picture] Calculating PP,", end="") 35 | self.info = self.beatmap.calculate_pp( 36 | mods=self.replay.mods, # type: ignore 37 | acc=self.replay.accuracy.value, # type: ignore 38 | combo=self.replay.max_combo, # type: ignore 39 | misses=self.replay.accuracy.hitmiss, # type: ignore 40 | ) 41 | 42 | print(" done!") 43 | 44 | def generate(self, style: int = 1, **kwargs: dict[Any, Any]) -> Path: 45 | custom_filename: str = kwargs.pop("custom_filename", "") # type: ignore 46 | 47 | settings: CanvasSettings = CanvasSettings( 48 | style=CanvasStyle(style), 49 | context=self, 50 | **kwargs, 51 | ) 52 | 53 | canvas: Canvas = Canvas.from_settings(settings=settings) 54 | 55 | image = canvas.generate() 56 | 57 | # Filename 58 | filename: str = DEFAULT_FILENAME_FORMAT 59 | 60 | if custom_filename: 61 | filename = custom_filename.removesuffix( 62 | ".png", 63 | ) # Remove any trailing .png just incase 64 | 65 | # Format the shit 66 | filename: str = filename.format( 67 | name=canvas.context.replay.player_name, 68 | artist=canvas.context.beatmap.artist, 69 | title=canvas.context.beatmap.title, 70 | diff=canvas.context.beatmap.difficulty, 71 | ) 72 | 73 | filename = filename 74 | 75 | result_image_path = OUTPUT_FOLDER / ( 76 | FILENAME_INVALID_REGEX.sub("", filename) + ".png" 77 | ) 78 | image.save(fp=result_image_path, format="png") 79 | 80 | return result_image_path 81 | 82 | @classmethod 83 | def from_replay_file( 84 | cls, 85 | replay_path: Path, 86 | beatmap_file: Path | None = None, 87 | ) -> Replay2Picture: 88 | print(f"[Replay2Picture] File: `{replay_path.name}`") 89 | 90 | self: Replay2Picture = cls() 91 | self.replay = ReplayInfo.from_file(replay_path) 92 | 93 | if not beatmap_file and self.replay.beatmap_md5: 94 | print("[Replay2Picture] No Beatmap file passed, getting from osu!") 95 | self.beatmap = Beatmap.from_md5(self.replay.beatmap_md5) 96 | elif beatmap_file: 97 | print("[Replay2Picture] Beatmap file passed, using that instead.") 98 | self.beatmap = Beatmap.from_osu_file(beatmap_file) 99 | 100 | return self 101 | 102 | @classmethod 103 | def from_beatmap_file(cls, beatmap_file: Path) -> Replay2Picture: 104 | ... 105 | -------------------------------------------------------------------------------- /app/generation/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from . import canvas 4 | from . import common 5 | from . import text 6 | -------------------------------------------------------------------------------- /app/generation/canvas.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable 4 | from dataclasses import dataclass 5 | from typing import TYPE_CHECKING 6 | 7 | from PIL import Image 8 | 9 | import app.utils 10 | from app.generation import styles 11 | from app.generation.common import CanvasSettings 12 | from app.generation.common import CanvasStyle 13 | from app.generation.common.vector import Vector2 14 | from app.generation.text.text import TextComponent 15 | 16 | if TYPE_CHECKING: 17 | from app.gazo import Replay2Picture 18 | 19 | 20 | @dataclass 21 | class DefaultAssets: 22 | avatar: Image.Image 23 | background: Image.Image 24 | star: Image.Image 25 | miss: Image.Image 26 | 27 | @classmethod 28 | def load_default_assets(cls, settings: CanvasSettings) -> DefaultAssets: 29 | avatar = Image.open(app.utils.CACHE_FOLDER / "default_avatar.png").convert( 30 | "RGBA", 31 | ) 32 | background = Image.open( 33 | app.utils.CACHE_FOLDER / "default_background.png", 34 | ).convert("RGBA") 35 | 36 | star = Image.open(app.utils.CACHE_FOLDER / "default_star.png").convert("RGBA") 37 | miss = Image.open(app.utils.CACHE_FOLDER / "default_miss.png").convert("RGBA") 38 | 39 | # Resize background to fit image 40 | background = app.utils.resize_image_to_resolution_but_keep_ratio( 41 | background, 42 | settings.resolution, 43 | ) 44 | 45 | return cls(avatar=avatar, background=background, star=star, miss=miss) 46 | 47 | 48 | @dataclass 49 | class Assets: 50 | default: DefaultAssets 51 | 52 | # 53 | font: TextComponent 54 | 55 | # 56 | background: Image.Image 57 | avatar: Image.Image 58 | 59 | 60 | class Canvas: 61 | def __init__(self) -> None: 62 | self.settings: CanvasSettings 63 | self.context: Replay2Picture 64 | self.assets: Assets 65 | 66 | self.canvas: Image.Image 67 | 68 | def generate(self) -> Image.Image: 69 | # Generate with style 70 | STYLE_GENERATOR: dict[CanvasStyle, Callable] = { 71 | CanvasStyle.default: styles.default.generate, 72 | CanvasStyle.akatsuki: styles.akatsuki.generate, 73 | } 74 | 75 | # NOTE: abit hacky 76 | STYLE_GENERATOR.get(self.settings.style, styles.default.generate)(self) 77 | 78 | # Convert to RGB format 79 | self.canvas = self.canvas.convert("RGB") 80 | 81 | return self.canvas 82 | 83 | @classmethod 84 | def from_settings(cls, settings: CanvasSettings) -> Canvas: 85 | canvas: Canvas = cls() 86 | canvas.settings = settings 87 | canvas.context = settings.context 88 | 89 | # Set up canvas 90 | canvas.canvas = Image.new( 91 | mode="RGBA", 92 | size=(canvas.settings.resolution.x, canvas.settings.resolution.y), # type: ignore | cope 93 | ) 94 | 95 | # Load Assets 96 | default = DefaultAssets.load_default_assets(settings=canvas.settings) 97 | background = app.utils.resize_image_to_resolution_but_keep_ratio( 98 | Image.open(canvas.context.beatmap.get_beatmap_background()), 99 | canvas.settings.resolution, 100 | ) 101 | 102 | avatar = Image.open(app.utils.get_player_avatar(canvas.context.replay.player_name)).convert("RGBA") # type: ignore 103 | 104 | canvas.assets = Assets( 105 | default=default, 106 | background=background, 107 | avatar=avatar, # type: ignore 108 | font=TextComponent(canvas.canvas, canvas.settings), 109 | ) 110 | 111 | return canvas 112 | -------------------------------------------------------------------------------- /app/generation/common/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from dataclasses import field 5 | from enum import Enum 6 | from typing import TYPE_CHECKING 7 | 8 | if TYPE_CHECKING: 9 | from app.gazo import Replay2Picture 10 | 11 | from . import vector 12 | 13 | 14 | class CanvasStyle(Enum): 15 | default = 1 16 | akatsuki = 2 17 | 18 | 19 | @dataclass 20 | class CanvasSettings: 21 | resolution: vector.Vector2 = field(default_factory=vector.Vector2(x=1920, y=1080)) # type: ignore 22 | style: CanvasStyle = field(default=CanvasStyle.default) 23 | context: Replay2Picture = field(default=None) # type: ignore 24 | 25 | # 26 | background_blur: float = field(default=5) 27 | background_dim: float = field(default=0.4) 28 | background_border: float = field(default=32) 29 | 30 | # 31 | message: str = field(default="") 32 | 33 | @property 34 | def scale(self) -> float: 35 | return self.resolution.y / 720.0 36 | -------------------------------------------------------------------------------- /app/generation/common/vector.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass 7 | class Vector2: 8 | x: float 9 | y: float 10 | -------------------------------------------------------------------------------- /app/generation/styles/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from . import akatsuki 4 | from . import default 5 | -------------------------------------------------------------------------------- /app/generation/styles/akatsuki/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from PIL import Image 6 | from PIL import ImageFilter 7 | from PIL import ImageOps 8 | 9 | import app.utils 10 | from app.generation.common import vector 11 | from app.generation.text.text import TEXT_DEFAULT_SCALE 12 | from app.generation.text.text import TextAlignment 13 | from app.generation.text.text import TextComponent 14 | 15 | if TYPE_CHECKING: 16 | from app.generation.canvas import Canvas 17 | 18 | __all__: list[str] = ["generate"] 19 | 20 | 21 | # Internals 22 | def _generate_background(canvas: Canvas) -> None: 23 | # Background 24 | if canvas.settings.background_blur: 25 | # If blur 26 | canvas.assets.background = canvas.assets.background.filter( 27 | ImageFilter.GaussianBlur(radius=canvas.settings.background_blur), 28 | ) 29 | 30 | canvas.canvas.paste(canvas.assets.background) 31 | 32 | # Dim 33 | dim = Image.new( 34 | "RGBA", 35 | size=( 36 | int(canvas.settings.resolution.x), 37 | int(canvas.settings.resolution.y), 38 | ), 39 | color=(0, 0, 0, int(255 * canvas.settings.background_dim)), 40 | ) 41 | 42 | canvas.canvas.paste( 43 | dim, 44 | ( 45 | int((canvas.settings.resolution.x - dim.width) / 2), 46 | int((canvas.settings.resolution.y - dim.height) / 2), 47 | ), 48 | mask=dim, 49 | ) 50 | 51 | canvas.canvas.paste( 52 | dim, 53 | ( 54 | int((canvas.settings.resolution.x - dim.width) / 2), 55 | int(160 * canvas.settings.scale), 56 | ), 57 | mask=dim, 58 | ) 59 | 60 | 61 | def _generate_line(canvas: Canvas) -> None: 62 | # Line 63 | top_space: int = int(160 * canvas.settings.scale) 64 | 65 | top_line_back = Image.new( 66 | "RGBA", 67 | (int(canvas.settings.resolution.x), int(6 * canvas.settings.scale)), 68 | (0, 0, 0, 255), 69 | ) 70 | 71 | top_line = Image.new( 72 | "RGBA", 73 | (int(canvas.settings.resolution.x), int(5 * canvas.settings.scale)), 74 | (255, 255, 255, 255), 75 | ) 76 | 77 | top_line_bloom = Image.new( 78 | "RGBA", 79 | (int(canvas.settings.resolution.x), int(100 * canvas.settings.scale)), 80 | ) 81 | 82 | _top_line_bloom_line = top_line.resize( 83 | (int(canvas.settings.resolution.x), int(10 * canvas.settings.scale)), 84 | ) 85 | 86 | top_line_bloom.paste( 87 | _top_line_bloom_line, 88 | ( 89 | int((top_line_bloom.width - _top_line_bloom_line.width) / 2), 90 | int((top_line_bloom.height - _top_line_bloom_line.height) / 2), 91 | ), 92 | mask=_top_line_bloom_line, 93 | ) 94 | 95 | top_line_bloom = top_line_bloom.filter(ImageFilter.GaussianBlur(10)) 96 | 97 | canvas.canvas.paste( 98 | top_line_bloom, 99 | (0, int(top_space - top_line_bloom.height / 2)), 100 | mask=top_line_bloom, 101 | ) 102 | 103 | canvas.canvas.paste( 104 | top_line_back, 105 | (0, int(top_space - top_line_back.height / 2)), 106 | mask=top_line_back, 107 | ) 108 | 109 | canvas.canvas.paste( 110 | top_line, 111 | (0, int(top_space - top_line.height / 2)), 112 | mask=top_line, 113 | ) 114 | 115 | 116 | def _generate_avatar(canvas: Canvas) -> None: 117 | # Avatar 118 | canvas.assets.avatar = app.utils.resize_image_to_resolution_but_keep_ratio( 119 | canvas.assets.avatar, 120 | vector.Vector2( 121 | x=200.0 * canvas.settings.scale, 122 | y=200.0 * canvas.settings.scale, 123 | ), 124 | ) 125 | 126 | avatar_with_border = Image.new( 127 | "RGBA", 128 | (int(205 * canvas.settings.scale), int(205 * canvas.settings.scale)), 129 | (220, 220, 220, 255), 130 | ) 131 | 132 | avatar_with_border.paste( 133 | canvas.assets.avatar, 134 | ( 135 | int((avatar_with_border.width - canvas.assets.avatar.width) / 2), 136 | int((avatar_with_border.height - canvas.assets.avatar.height) / 2), 137 | ), 138 | mask=canvas.assets.avatar, 139 | ) 140 | 141 | canvas.canvas.paste( 142 | avatar_with_border, 143 | ( 144 | int(40 * canvas.settings.scale), 145 | int(220 * canvas.settings.scale), 146 | ), 147 | ) 148 | 149 | 150 | def _generate_text(canvas: Canvas) -> None: 151 | # Text 152 | canvas.assets.font.draw_text( 153 | f"{canvas.context.replay.player_name}", 154 | alignment=TextAlignment.centre, 155 | offset=[0, -650], 156 | outline_stroke=4, 157 | outline_color=(0, 0, 0), 158 | shadow_color=None, 159 | text_size=int(TEXT_DEFAULT_SCALE * 1.2), 160 | bloom_color=(255, 255, 255), 161 | ) 162 | 163 | # Artist 164 | canvas.assets.font.draw_text( 165 | f"{canvas.context.beatmap.artist}", 166 | alignment=TextAlignment.centre, 167 | offset=[100, -320], 168 | shadow_color=None, 169 | text_size=int(TEXT_DEFAULT_SCALE * 0.9), 170 | bloom_color=(255, 255, 255), 171 | text_canvas_size=[800 * canvas.settings.scale, 800 * canvas.settings.scale], 172 | ) 173 | 174 | # Title 175 | canvas.assets.font.draw_text( 176 | f"{canvas.context.beatmap.title}", 177 | alignment=TextAlignment.centre, 178 | offset=[100, -160], 179 | shadow_color=None, 180 | text_size=int(TEXT_DEFAULT_SCALE * 0.9), 181 | color=(255, 255, 100), 182 | bloom_color=(255, 255, 100), 183 | text_canvas_size=[800 * canvas.settings.scale, 800 * canvas.settings.scale], 184 | ) 185 | 186 | # Diff 187 | canvas.assets.font.draw_text( 188 | f"[{canvas.context.beatmap.difficulty}] +{canvas.context.replay.mods!r} | {canvas.context.replay.max_combo}/{canvas.context.beatmap.max_combo}x", 189 | alignment=TextAlignment.centre, 190 | offset=[100, 50], 191 | shadow_color=None, 192 | text_size=int(TEXT_DEFAULT_SCALE * 0.9), 193 | bloom_color=(255, 255, 255), 194 | text_canvas_size=[800 * canvas.settings.scale, 800 * canvas.settings.scale], 195 | ) 196 | 197 | # PP 198 | canvas.assets.font.draw_text( 199 | f"{canvas.context.info.pp:.0f}pp", # type: ignore 200 | alignment=TextAlignment.centre, 201 | offset=[0, 350], 202 | shadow_color=None, 203 | text_size=int(TEXT_DEFAULT_SCALE * 1.2), 204 | bloom_color=(255, 255, 255), 205 | ) 206 | 207 | # Acc 208 | canvas.assets.font.draw_text( 209 | f"{canvas.context.replay.accuracy.value:.2f}%", # type: ignore 210 | alignment=TextAlignment.centre, 211 | offset=[350, 300], 212 | shadow_color=None, 213 | text_size=int(TEXT_DEFAULT_SCALE * 0.9), 214 | bloom_color=(255, 255, 255), 215 | ) 216 | 217 | # Judge 218 | judge_text: str = "FC" 219 | judge_color: tuple[int, int, int] = (255, 255, 100) 220 | 221 | # NOTE: If missed for sure. 222 | if canvas.context.replay.accuracy and canvas.context.replay.accuracy.hitmiss: 223 | judge_text = f"{canvas.context.replay.accuracy.hitmiss}xMiss" 224 | judge_color = (255, 0, 0) 225 | 226 | # HACK: If there's no misses but combo doesnt reach >= 60% of the max beatmap combo, 227 | # HACK: Just show "?" 228 | if (canvas.context.replay.max_combo / canvas.context.beatmap.max_combo) <= 0.6 and judge_text == "FC": # type: ignore 229 | judge_text = "?" 230 | judge_color = (255, 255, 0) 231 | 232 | canvas.assets.font.draw_text( 233 | judge_text, # type: ignore 234 | alignment=TextAlignment.centre, 235 | offset=[350, 450], 236 | shadow_color=None, 237 | text_size=int(TEXT_DEFAULT_SCALE * 0.9), 238 | color=judge_color, 239 | bloom_color=judge_color, 240 | ) 241 | 242 | 243 | def generate(canvas: Canvas) -> None: 244 | print("[Style::Akatsuki] Generating!") 245 | 246 | _generate_background(canvas) 247 | _generate_line(canvas) 248 | _generate_avatar(canvas) 249 | _generate_text(canvas) 250 | -------------------------------------------------------------------------------- /app/generation/styles/default/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from PIL import Image 6 | from PIL import ImageFilter 7 | 8 | from app.generation.common import vector 9 | from app.generation.text.text import TEXT_DEFAULT_SCALE 10 | from app.generation.text.text import TextAlignment 11 | from app.generation.text.text import TextComponent 12 | 13 | if TYPE_CHECKING: 14 | from app.generation.canvas import Canvas 15 | 16 | import app.utils 17 | 18 | 19 | def _generate_background(canvas: Canvas) -> None: 20 | # Background 21 | if canvas.settings.background_blur: 22 | # If blur 23 | canvas.assets.background = canvas.assets.background.filter( 24 | ImageFilter.GaussianBlur(radius=canvas.settings.background_blur), 25 | ) 26 | 27 | canvas.canvas.paste(canvas.assets.background) 28 | 29 | # Dim 30 | dim = Image.new( 31 | "RGBA", 32 | size=( 33 | int( 34 | canvas.settings.resolution.x 35 | - (canvas.settings.background_border * canvas.settings.scale), 36 | ), 37 | int( 38 | canvas.settings.resolution.y 39 | - (canvas.settings.background_border * canvas.settings.scale), 40 | ), 41 | ), 42 | color=(0, 0, 0, int(255 * canvas.settings.background_dim)), 43 | ) 44 | 45 | canvas.canvas.paste( 46 | dim, 47 | ( 48 | int((canvas.settings.resolution.x - dim.width) / 2), 49 | int((canvas.settings.resolution.y - dim.height) / 2), 50 | ), 51 | mask=dim, 52 | ) 53 | 54 | 55 | def _generate_avatar(canvas: Canvas) -> None: 56 | # Avatar 57 | canvas.assets.avatar = app.utils.resize_image_to_resolution_but_keep_ratio( 58 | canvas.assets.avatar, 59 | vector.Vector2( 60 | x=200.0 * canvas.settings.scale, 61 | y=200.0 * canvas.settings.scale, 62 | ), 63 | ) 64 | 65 | avatar_with_border = Image.new( 66 | "RGBA", 67 | (int(205 * canvas.settings.scale), int(205 * canvas.settings.scale)), 68 | (220, 220, 220, 255), 69 | ) 70 | 71 | avatar_with_border.paste( 72 | canvas.assets.avatar, 73 | ( 74 | int((avatar_with_border.width - canvas.assets.avatar.width) / 2), 75 | int((avatar_with_border.height - canvas.assets.avatar.height) / 2), 76 | ), 77 | mask=canvas.assets.avatar, 78 | ) 79 | 80 | canvas.canvas.paste( 81 | avatar_with_border, 82 | ( 83 | int((canvas.settings.resolution.x - avatar_with_border.width) / 2), 84 | int((canvas.settings.resolution.y - avatar_with_border.height) / 2), 85 | ), 86 | ) 87 | 88 | 89 | def _generate_text(canvas: Canvas) -> None: 90 | # Text 91 | 92 | # Title 93 | canvas.assets.font.draw_text( 94 | f"{canvas.context.beatmap.artist} - {canvas.context.beatmap.title}", 95 | alignment=TextAlignment.centre, 96 | offset=[0, -550], 97 | ) 98 | 99 | # Diff 100 | canvas.assets.font.draw_text( 101 | f"[{canvas.context.beatmap.difficulty}]", 102 | alignment=TextAlignment.centre, 103 | offset=[0, -400], 104 | ) 105 | 106 | # Acc 107 | canvas.assets.font.draw_text( 108 | f"{canvas.context.replay.accuracy.value:.2f}%", # type: ignore 109 | alignment=TextAlignment.right, 110 | offset=[-120, -100], 111 | ) 112 | 113 | # Mods 114 | canvas.assets.font.draw_text( 115 | f"{canvas.context.replay.mods}", # type: ignore 116 | alignment=TextAlignment.right, 117 | offset=[-120, 60], 118 | ) 119 | 120 | # Star 121 | canvas.assets.default.star = app.utils.resize_image_to_resolution_but_keep_ratio( 122 | canvas.assets.default.star, 123 | vector.Vector2(x=60.0 * canvas.settings.scale, y=60.0 * canvas.settings.scale), 124 | ) 125 | 126 | star_text_position = canvas.assets.font.draw_text( 127 | f"{canvas.context.info.difficulty.stars:.2f}", # type: ignore 128 | alignment=TextAlignment.left, 129 | offset=[120, -100], 130 | ) 131 | 132 | canvas.canvas.paste( 133 | canvas.assets.default.star, 134 | [star_text_position.x, star_text_position.y], # type: ignore 135 | mask=canvas.assets.default.star, 136 | ) 137 | 138 | # PP 139 | canvas.assets.font.draw_text( 140 | f"{canvas.context.info.pp:.0f}pp", # type: ignore 141 | alignment=TextAlignment.left, 142 | offset=[120, 60], 143 | ) 144 | 145 | # Miss (if theres any) 146 | if canvas.context.replay.accuracy and canvas.context.replay.accuracy.hitmiss: 147 | canvas.assets.default.miss = ( 148 | app.utils.resize_image_to_resolution_but_keep_ratio( 149 | canvas.assets.default.miss, 150 | vector.Vector2( 151 | x=120 * canvas.settings.scale, 152 | y=120 * canvas.settings.scale, 153 | ), 154 | ) 155 | ) 156 | 157 | # If theres message we can make the miss text smaller (by like 0.85) 158 | miss_text_scale: float = 1.0 159 | if canvas.settings.message: 160 | miss_text_scale = 0.75 161 | 162 | canvas.assets.font.draw_text( 163 | f"{canvas.context.replay.accuracy.hitmiss}xMiss", # type: ignore 164 | alignment=TextAlignment.centre, 165 | offset=[0, 240], 166 | text_size=int(TEXT_DEFAULT_SCALE * miss_text_scale), 167 | color=(255, 0, 0), 168 | outline_color=(139, 10, 0), 169 | outline_stroke=4, 170 | shadow_color=None, 171 | ) 172 | 173 | if canvas.settings.message: 174 | y_offset: float = 0.0 175 | 176 | if canvas.context.replay.accuracy and canvas.context.replay.accuracy.hitmiss: 177 | y_offset += 100.0 178 | 179 | canvas.assets.font.draw_text( 180 | canvas.settings.message, # type: ignore 181 | alignment=TextAlignment.centre, 182 | offset=[0, int(240 + y_offset)], 183 | text_size=int(TEXT_DEFAULT_SCALE * 1.4), 184 | ) 185 | 186 | 187 | def generate(canvas: Canvas) -> None: 188 | print("[Style::Default] Generating!") 189 | 190 | _generate_background(canvas) 191 | _generate_avatar(canvas) 192 | _generate_text(canvas) 193 | -------------------------------------------------------------------------------- /app/generation/text/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from . import text 4 | -------------------------------------------------------------------------------- /app/generation/text/text.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import IntEnum 4 | from typing import Any 5 | 6 | from PIL import Image 7 | from PIL import ImageDraw 8 | from PIL import ImageEnhance 9 | from PIL import ImageFilter 10 | from PIL import ImageFont 11 | from PIL import ImageOps 12 | 13 | from app.generation.common import CanvasSettings 14 | from app.generation.common import vector 15 | from app.utils import CACHE_FOLDER 16 | 17 | # 18 | TEXT_DEFAULT_SCALE: int = 55 19 | 20 | 21 | class TextAlignment(IntEnum): 22 | left = 1 23 | centre = 2 24 | right = 3 25 | 26 | 27 | class TextComponent: 28 | def __init__(self, canvas: Image.Image, settings: CanvasSettings) -> None: 29 | self.settings = settings 30 | self.canvas = canvas 31 | self.font = self.make_font(size=TEXT_DEFAULT_SCALE) 32 | self.draw = ImageDraw.Draw(canvas) 33 | 34 | def make_font(self, size: float = TEXT_DEFAULT_SCALE) -> ImageFont.FreeTypeFont: 35 | return ImageFont.truetype( 36 | str(CACHE_FOLDER / "font.ttf"), 37 | size=int(size * self.settings.scale), 38 | ) 39 | 40 | def draw_text( 41 | self, 42 | text: str, 43 | color: tuple[int, int, int] = (255, 255, 255), 44 | shadow_color: tuple[int, int, int] | None = (0, 0, 0), 45 | outline_color: tuple[int, int, int] | None = (0, 0, 0), 46 | outline_stroke: int = 2, 47 | alignment: TextAlignment = TextAlignment.left, 48 | text_size: int = TEXT_DEFAULT_SCALE, 49 | offset: list[int] = [0, 0], 50 | bloom_color: tuple[int, int, int] | None = None, 51 | bloom_size: float = 1.0, 52 | text_canvas_size: list[float] | None = None, 53 | ) -> vector.Vector2: 54 | font = self.font 55 | font_size = text_size * self.settings.scale 56 | pos_x, pos_y = (_ * self.settings.scale for _ in offset) 57 | 58 | if not text_canvas_size: 59 | _bypass_sane_check = False 60 | text_canvas_size = [self.settings.resolution.x, self.settings.resolution.y] 61 | 62 | # Truncate 63 | if len(text) > 80: 64 | text = text[:80] + "..." 65 | 66 | # Special case 67 | if text_size != TEXT_DEFAULT_SCALE: 68 | font = self.make_font(font_size) 69 | 70 | # Make sure text fits the screen 71 | while ( 72 | font.getbbox(text)[2] > text_canvas_size[0] 73 | and font_size > 15 * self.settings.scale 74 | ): 75 | font_size -= 1 76 | font = self.make_font(font_size) 77 | 78 | # Font size 79 | _, _, text_width, text_height = self.draw.textbbox( 80 | xy=(0, 0), 81 | text=text, 82 | font=font, 83 | ) 84 | 85 | # Text alignment 86 | if alignment == TextAlignment.left: 87 | pos_x = (self.settings.resolution.x - text_width + text_width) / 2 + pos_x 88 | pos_y = (self.settings.resolution.y + pos_y) / 2 89 | elif alignment == TextAlignment.centre: 90 | pos_x = (self.settings.resolution.x - text_width) / 2 + pos_x 91 | pos_y = (self.settings.resolution.y + pos_y) / 2 92 | else: 93 | pos_x = (self.settings.resolution.x - text_width - text_width) / 2 + pos_x 94 | pos_y = (self.settings.resolution.y + pos_y) / 2 95 | 96 | # Shadow Position 97 | shadow_x, shadow_y = ( 98 | position + 5 * self.settings.scale for position in [pos_x, pos_y] 99 | ) 100 | 101 | # NOTE: bloom 102 | # HACK: This is fucked, like really fucked. 103 | if bloom_color: 104 | _bloom_font_scale: float = 1.1 * bloom_size 105 | _bloom_canvas: Image.Image = Image.new( 106 | "RGBA", 107 | ( 108 | int(text_width), 109 | int(text_height), 110 | ), 111 | (0, 0, 0, 0), 112 | ) 113 | 114 | _bloom_draw: ImageDraw.ImageDraw = ImageDraw.Draw(_bloom_canvas) 115 | _bloom_draw.text((0, 0), text, fill=bloom_color, font=font) 116 | 117 | _bloom_canvas = _bloom_canvas.resize( 118 | ( 119 | int(text_width * _bloom_font_scale), 120 | int(text_height * _bloom_font_scale), 121 | ), 122 | ) 123 | 124 | _bloom_bloom_space: float = 4 125 | 126 | _bloom_canvas = ImageOps.expand( 127 | _bloom_canvas, 128 | ((text_width * _bloom_bloom_space) - _bloom_canvas.width) // 2, 129 | ) 130 | 131 | _bloom_canvas = _bloom_canvas.filter(ImageFilter.GaussianBlur(50)) 132 | _bloom_canvas_brightness = ImageEnhance.Brightness(_bloom_canvas) 133 | 134 | for _ in range(1, 2): 135 | # HACK: lol fuck 136 | _bloom_canvas = _bloom_canvas_brightness.enhance(3) 137 | self.canvas.paste( 138 | _bloom_canvas, 139 | ( 140 | int(pos_x - (text_width * 3) / 2), 141 | int(pos_y - (text_width * 2.95) / 2), 142 | ), 143 | mask=_bloom_canvas, 144 | ) 145 | 146 | # shadow_color = None 147 | # outline_color = None 148 | 149 | if shadow_color: # Can be nullable to disable shadow 150 | self.draw.text((shadow_x, shadow_y), text, fill=shadow_color, font=font) 151 | 152 | extra_args: dict[str, Any] = {} 153 | 154 | if outline_color: 155 | extra_args |= {"stroke_width": outline_stroke, "stroke_fill": outline_color} 156 | 157 | self.draw.text((pos_x, pos_y), text, fill=color, font=font, **extra_args) 158 | 159 | # Return position next to text 160 | return vector.Vector2( 161 | x=int(pos_x + text_width + 5 * self.settings.scale), 162 | y=int(pos_y), 163 | ) 164 | -------------------------------------------------------------------------------- /app/objects/api.py: -------------------------------------------------------------------------------- 1 | """ api.py - v1 and v2 api wrapper """ 2 | from __future__ import annotations 3 | 4 | from pathlib import Path 5 | from typing import Self 6 | 7 | import requests 8 | from ossapi import Ossapi 9 | from ossapi import UserLookupKey 10 | 11 | # Errors 12 | MISSING_FILE = "[Error] Failed to read api key file [apikey.txt], create it." 13 | EMPTY_FILE = "[API] No values found in api key file [apikey.txt]." 14 | SOLUTION_API_V1 = ( 15 | "[API] For API v1: https://osu.ppy.sh/p/api \n" 16 | + "Solution: Put your api key on the first line in 'apikey.txt'" 17 | ) 18 | SOLUTION_API_V2 = ( 19 | "[API] For API v2: https://osu.ppy.sh/home/account/edit#oauth \n" 20 | + "Solution: Put your client_id and client_secret on separate lines in 'apikey.txt'" 21 | ) 22 | 23 | 24 | class LegacyAPI: 25 | def __init__(self, key: str) -> None: 26 | self.session: requests.Session = requests.Session() 27 | self.key: str = key 28 | 29 | def get_player_id(self, name: str) -> int | None: 30 | with self.session.get( 31 | f"https://osu.ppy.sh/api/get_user", 32 | params={"k": self.key, "u": name}, 33 | ) as res: 34 | if not res or res.status_code != 200: 35 | print("[API] Failed to get player id from osu! v1 api!") 36 | return None 37 | 38 | return res.json()[0].get("user_id", -1) 39 | 40 | return -1 41 | 42 | def get_beatmap_id_from_md5(self, md5: str) -> int: 43 | with self.session.get( 44 | f"https://osu.ppy.sh/api/get_beatmaps", 45 | params={"k": self.key, "h": md5}, 46 | ) as res: 47 | if not res or res.status_code != 200: 48 | print("[API] Failed to get beatmap id from osu! v1 api!") 49 | return None 50 | 51 | return res.json()[0].get("beatmap_id", -1) 52 | 53 | 54 | class ModernAPI(LegacyAPI): 55 | def __init__(self, client_id: str, client_secret: str) -> None: 56 | self.client: Ossapi = Ossapi(client_id, client_secret) 57 | 58 | def get_player_id(self, name: str) -> int | None: 59 | user = self.client.user(name, key=UserLookupKey.USERNAME) 60 | return user.id if user else None 61 | 62 | def get_beatmap_id_from_md5(self, md5: str) -> int: 63 | return self.client.beatmap(checksum=md5).id 64 | 65 | 66 | class APIWrapper: 67 | def __init__(self) -> None: 68 | ... 69 | 70 | @classmethod 71 | def from_api_v1_key(cls, key: str) -> Self: 72 | return LegacyAPI(key) 73 | 74 | @classmethod 75 | def from_api_v2_key(cls, client_id: str, client_secret: str) -> Self: 76 | return ModernAPI(client_id, client_secret) 77 | 78 | @classmethod 79 | def from_file(cls, file: Path) -> Self | None: 80 | if not file.exists(): 81 | print(MISSING_FILE) 82 | print(SOLUTION_API_V1) 83 | print(SOLUTION_API_V2) 84 | raise SystemExit(1) 85 | 86 | # Read values 87 | lines: list[str] = [ 88 | line for line in file.read_text().split("\n") if line.strip() 89 | ] 90 | 91 | # Empty file 92 | if not lines: 93 | print(EMPTY_FILE) 94 | print(SOLUTION_API_V1) 95 | print(SOLUTION_API_V2) 96 | raise SystemExit(1) 97 | 98 | # V1 99 | if len(lines) == 1: 100 | return cls.from_api_v1_key(lines[0]) 101 | 102 | # V2 103 | if len(lines) == 2: 104 | return cls.from_api_v2_key(lines[0], lines[1]) 105 | 106 | # Invalid 107 | print("[API] Invalid api key file.") 108 | print(SOLUTION_API_V1) 109 | print(SOLUTION_API_V2) 110 | raise SystemExit(1) 111 | -------------------------------------------------------------------------------- /app/objects/beatmap.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | 5 | import requests 6 | from rosu_pp_py import Beatmap as PPBeatmap 7 | from rosu_pp_py import Performance 8 | from rosu_pp_py import PerformanceAttributes 9 | 10 | import app.utils 11 | from app.objects import api 12 | 13 | # 14 | CACHE_FOLDER: Path = app.utils.CACHE_FOLDER / "osu" 15 | SOMETHING_FUCKED_UP: str = "SOMETHING FUCKED UP" 16 | 17 | # 18 | CACHE_FOLDER.mkdir(exist_ok=True, parents=True) 19 | 20 | # URL(s) 21 | OSU_RAW_URL: str = "https://osu.ppy.sh/osu/{id}" 22 | OSU_BACKGROUND_URL: str = "https://assets.ppy.sh/beatmaps/{set_id}/covers/fullsize.jpg" 23 | KITSU_MD5_URL: str = "https://osu.direct/api/md5/{md5}" 24 | 25 | # Internal 26 | USER_AGENT: str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36" 27 | 28 | 29 | class Beatmap: 30 | data: dict[str, dict[str, str]] 31 | 32 | def __init__( 33 | self, 34 | data: dict[str, dict[str, str]] = {}, 35 | beatmap_path: Path | None = None, 36 | ) -> None: 37 | self.data: dict[str, dict[str, str]] = data 38 | self.http: requests.Session = requests.Session() 39 | self.api_client: api.APIWrapper = app.utils.get_api_client() 40 | self.path = beatmap_path 41 | 42 | self.http.headers.update({"User-Agent": USER_AGENT}) # Set header 43 | 44 | @property 45 | def id(self) -> int: 46 | return int(self.data.get("Metadata", {}).get("BeatmapID", 0)) 47 | 48 | @property 49 | def set_id(self) -> int: 50 | return int(self.data.get("Metadata", {}).get("BeatmapSetID", 0)) 51 | 52 | @property 53 | def artist(self) -> str: 54 | return self.data.get("Metadata", {}).get("Artist", SOMETHING_FUCKED_UP) 55 | 56 | @property 57 | def title(self) -> str: 58 | return self.data.get("Metadata", {}).get("Title", SOMETHING_FUCKED_UP) 59 | 60 | @property 61 | def difficulty(self) -> str: 62 | return self.data.get("Metadata", {}).get("Version", SOMETHING_FUCKED_UP) 63 | 64 | @property 65 | def max_combo(self) -> int: 66 | return int(self.data.get("Metadata", {}).get("MaxCombo", 0)) 67 | 68 | """ calcs """ 69 | 70 | def calculate_pp( 71 | self, 72 | mods: int, 73 | acc: float, 74 | combo: int, 75 | misses: int, 76 | ) -> PerformanceAttributes: 77 | if self.path and not self.path.exists(): 78 | print( 79 | "[Beatmap] The fuck, cached beatmap file is gone... Try running the thing again?", 80 | ) 81 | 82 | pp_bmap = PPBeatmap(path=str(self.path)) 83 | pp_calc = Performance(mods=mods) 84 | 85 | # params 86 | pp_calc.set_accuracy(acc) 87 | pp_calc.set_combo(combo) 88 | pp_calc.set_misses(misses) 89 | 90 | return pp_calc.calculate(pp_bmap) 91 | 92 | """ files """ 93 | 94 | def get_beatmap_background(self) -> Path: 95 | # Download background if doesnt exists 96 | if not (background_file := CACHE_FOLDER / f"{self.set_id}_bg.png").exists(): 97 | print("[API] Getting beatmap background from osu! /assets/,", end="") 98 | with self.http.get(OSU_BACKGROUND_URL.format(set_id=self.set_id)) as res: 99 | if res.status_code != 200: 100 | print(" failed.") 101 | print( 102 | "[API] Failed to get beatmap background, using the default one.", 103 | ) 104 | return app.utils.CACHE_FOLDER / "default_background.png" 105 | 106 | print(" success!") 107 | background_file.write_bytes(res.content) 108 | 109 | return background_file 110 | 111 | """ factories """ 112 | 113 | def get_id_from_md5_kitsu(self, md5: str) -> int: 114 | with self.http.get(KITSU_MD5_URL.format(md5=md5)) as res: 115 | if res.status_code != 200: 116 | print("[API] Failed to get beatmap id from kitsu!") 117 | return 0 118 | 119 | return res.json().get("BeatmapID", 0) 120 | 121 | def get_id_from_md5_osu(self, md5: str) -> int: 122 | return self.api_client.get_beatmap_id_from_md5(md5) 123 | 124 | @classmethod 125 | def from_md5(cls, md5: str): 126 | beatmap: Beatmap = cls() 127 | 128 | current_id: int = 0 129 | 130 | for api_method in [ 131 | beatmap.get_id_from_md5_osu, 132 | beatmap.get_id_from_md5_kitsu, 133 | ]: 134 | print( 135 | f"[API] Trying to get beatmap id from {api_method.__name__}, ", 136 | end="", 137 | ) 138 | try: 139 | if (current_id := api_method(md5)) != 0: 140 | print("success!") 141 | break 142 | except Exception as err: 143 | print(f"failed. Reason: {err}") 144 | continue 145 | 146 | if current_id == 0: 147 | print("[API] Failed to get beatmap id from all sources!") 148 | return None 149 | 150 | bmap = cls.from_id(current_id) 151 | 152 | return bmap 153 | 154 | @classmethod 155 | def from_id(cls, id: int): 156 | beatmap: Beatmap = cls() 157 | 158 | # Get raw .osu file from osu, if not in cache 159 | if not (beatmap_file := CACHE_FOLDER / str(id)).exists(): 160 | print("[API] Getting beatmap from osu! /osu/,", end="") 161 | 162 | with beatmap.http.get(OSU_RAW_URL.format(id=id)) as res: 163 | if res.status_code != 200: 164 | print(" failed.") 165 | print("[API] Failed to get beatmap file from osu!.") 166 | print( 167 | "[API] If this is a custom beatmap, please pass the beatmap path with `-b` param.", 168 | ) 169 | raise SystemExit(1) 170 | 171 | print(" success!") 172 | beatmap_file.write_bytes(res.content) 173 | beatmap.path = beatmap_file 174 | 175 | return beatmap.from_osu_file(beatmap_file) 176 | 177 | @classmethod 178 | def from_osu_file(cls, path: Path) -> Beatmap: 179 | beatmap: Beatmap = cls(beatmap_path=path) 180 | 181 | beatmap.data |= beatmap._parse_beatmap_file_from_path(path) 182 | return beatmap 183 | 184 | """ spooky shit """ 185 | 186 | @staticmethod 187 | def _parse_beatmap_file_from_path(path: Path) -> dict[str, dict[str, str]]: 188 | """really quick and dirty beatmap parser""" 189 | 190 | data: dict[str, dict[str, str]] = {} 191 | category: str = "" 192 | 193 | # NOTE: in linux this works just fine 194 | # but on windows thing just shits the bed, fuck you windows. 195 | for line in ( 196 | path.read_bytes().decode(encoding="utf-8", errors="ignore").splitlines() 197 | ): 198 | if not line.strip(): 199 | continue 200 | 201 | if line.startswith("["): 202 | category = line.replace("[", "").replace("]", "") 203 | data[category] = {} 204 | continue 205 | 206 | match category: 207 | case "General" | "Editor" | "Metadata" | "Difficulty": 208 | items = line.split(":") 209 | data[category][items[0]] = items[1] 210 | return data 211 | 212 | 213 | if __name__ == "__main__": 214 | b = Beatmap.from_id(2690223) 215 | print(b.title) 216 | print(b.difficulty) 217 | print(b.calculate_pp(mods=16, acc=100.0, combo=532, misses=0)) 218 | -------------------------------------------------------------------------------- /app/objects/replay.py: -------------------------------------------------------------------------------- 1 | """ 2 | replay.py - ripped off from cmyui's common lib 3 | """ 4 | from __future__ import annotations 5 | 6 | import os 7 | from dataclasses import dataclass 8 | from enum import IntEnum 9 | from enum import IntFlag 10 | from enum import unique 11 | from pathlib import Path 12 | from typing import Optional 13 | 14 | 15 | @unique 16 | class Mods(IntFlag): 17 | NOMOD = 0 18 | NOFAIL = 1 << 0 19 | EASY = 1 << 1 20 | TOUCHSCREEN = 1 << 2 # old: 'NOVIDEO' 21 | HIDDEN = 1 << 3 22 | HARDROCK = 1 << 4 23 | SUDDENDEATH = 1 << 5 24 | DOUBLETIME = 1 << 6 25 | RELAX = 1 << 7 26 | HALFTIME = 1 << 8 27 | NIGHTCORE = 1 << 9 28 | FLASHLIGHT = 1 << 10 29 | AUTOPLAY = 1 << 11 30 | SPUNOUT = 1 << 12 31 | AUTOPILOT = 1 << 13 32 | PERFECT = 1 << 14 33 | KEY4 = 1 << 15 34 | KEY5 = 1 << 16 35 | KEY6 = 1 << 17 36 | KEY7 = 1 << 18 37 | KEY8 = 1 << 19 38 | FADEIN = 1 << 20 39 | RANDOM = 1 << 21 40 | CINEMA = 1 << 22 41 | TARGET = 1 << 23 42 | KEY9 = 1 << 24 43 | KEYCOOP = 1 << 25 44 | KEY1 = 1 << 26 45 | KEY3 = 1 << 27 46 | KEY2 = 1 << 28 47 | SCOREV2 = 1 << 29 48 | MIRROR = 1 << 30 49 | 50 | # XXX: needs some modification to work.. 51 | # KEY_MOD = KEY1 | KEY2 | KEY3 | KEY4 | KEY5 | KEY6 | KEY7 | KEY8 | KEY9 | KEYCOOP 52 | # FREE_MOD_ALLOWED = NOFAIL | EASY | HIDDEN | HARDROCK | \ 53 | # SUDDENDEATH | FLASHLIGHT | FADEIN | \ 54 | # RELAX | AUTOPILOT | SPUNOUT | KEY_MOD 55 | # SCORE_INCREASE_MODS = HIDDEN | HARDROCK | DOUBLETIME | FLASHLIGHT | FADEIN 56 | SPEED_CHANGING = DOUBLETIME | NIGHTCORE | HALFTIME 57 | 58 | def __str__(self) -> str: 59 | return self.__repr__() 60 | 61 | def __repr__(self) -> str: 62 | # NM returns NM 63 | if self.value == Mods.NOMOD: 64 | return "NM" 65 | 66 | # Filter DT if NC 67 | value: int = self.value 68 | 69 | if value & Mods.NIGHTCORE: 70 | value -= Mods.DOUBLETIME 71 | 72 | mod_dict = { 73 | Mods.NOFAIL: "NF", 74 | Mods.EASY: "EZ", 75 | Mods.TOUCHSCREEN: "TD", 76 | Mods.HIDDEN: "HD", 77 | Mods.HARDROCK: "HR", 78 | Mods.SUDDENDEATH: "SD", 79 | Mods.DOUBLETIME: "DT", 80 | Mods.RELAX: "RX", 81 | Mods.HALFTIME: "HT", 82 | Mods.NIGHTCORE: "NC", 83 | Mods.FLASHLIGHT: "FL", 84 | Mods.AUTOPLAY: "AU", 85 | Mods.SPUNOUT: "SO", 86 | Mods.AUTOPILOT: "AP", 87 | Mods.PERFECT: "PF", 88 | Mods.KEY4: "K4", 89 | Mods.KEY5: "K5", 90 | Mods.KEY6: "K6", 91 | Mods.KEY7: "K7", 92 | Mods.KEY8: "K8", 93 | Mods.FADEIN: "FI", 94 | Mods.RANDOM: "RN", 95 | Mods.CINEMA: "CN", 96 | Mods.TARGET: "TP", 97 | Mods.KEY9: "K9", 98 | Mods.KEYCOOP: "CO", 99 | Mods.KEY1: "K1", 100 | Mods.KEY3: "K3", 101 | Mods.KEY2: "K2", 102 | Mods.SCOREV2: "V2", 103 | Mods.MIRROR: "MR", 104 | } 105 | 106 | mod_str = [] 107 | 108 | for m in (_m for _m in Mods if value & _m and _m != Mods.SPEED_CHANGING): 109 | mod_str.append(mod_dict[m]) 110 | 111 | return "".join(mod_str) 112 | 113 | 114 | class Mode(IntEnum): 115 | standard = 0 116 | taiko = 1 117 | ctb = 2 118 | mania = 3 119 | 120 | 121 | @dataclass 122 | class Accuracy: 123 | hit300: float 124 | hit100: float 125 | hit50: float 126 | hitgeki: float 127 | hitkatu: float 128 | hitmiss: float 129 | 130 | @property 131 | def value(self) -> float: 132 | # :troll: 133 | return ( 134 | ( 135 | (self.hit300) * 300.0 136 | + (self.hit100) * 100.0 137 | + (self.hit50) * 50.0 138 | + (self.hitmiss) * 0.0 139 | ) 140 | / ((self.hit300 + self.hit100 + self.hit50 + self.hitmiss) * 300.0) 141 | ) * 100 142 | 143 | 144 | class ReplayInfo: 145 | def __init__(self) -> None: 146 | self.mode: Mode | None = None 147 | self.client_version: int | None = None 148 | 149 | self.beatmap_md5: str | None = None 150 | self.player_name: str | None = None 151 | self.replay_md5: str | None = None 152 | 153 | self.accuracy: Accuracy | None = None 154 | self.score: int | None = None 155 | self.max_combo: int | None = None 156 | self.is_perfect: bool | None = None 157 | self.mods: Mods | None = None 158 | 159 | self.view: memoryview | None = None 160 | 161 | @classmethod 162 | def from_file(cls, filepath: str | Path) -> ReplayInfo: 163 | if not (path := Path(filepath)).exists(): 164 | print("[Replay] Failed to load replay file, exiting!") 165 | os._exit(1) 166 | 167 | replay: ReplayInfo = cls() 168 | replay.view = memoryview(path.read_bytes()) 169 | replay.parse() 170 | 171 | print("[Replay] Replay loaded!") 172 | 173 | return replay 174 | 175 | def parse(self) -> None: 176 | self.mode = Mode.from_bytes(self.read_byte(), "little") 177 | 178 | self.client_version = self.read_int() 179 | self.beatmap_md5 = self.read_string() 180 | self.player_name = self.read_string() 181 | self.replay_md5 = self.read_string() 182 | 183 | self.accuracy = Accuracy( 184 | hit300=self.read_short(), # type: ignore 185 | hit100=self.read_short(), # type: ignore 186 | hit50=self.read_short(), # type: ignore 187 | hitgeki=self.read_short(), # type: ignore 188 | hitkatu=self.read_short(), # type: ignore 189 | hitmiss=self.read_short(), # type: ignore 190 | ) # suck my dick 191 | 192 | self.score = self.read_int() 193 | self.max_combo = self.read_short() 194 | self.is_perfect = self.read_byte() == 0x01 195 | self.mods = Mods(self.read_int()) 196 | 197 | # read FNs 198 | def read_byte(self, length: int = 1) -> bytes: 199 | if ( 200 | self.view 201 | ): # NOTE: fuckin lint kept saying "oh no this might be none ohhhh nooo!!!!" 202 | val = self.view[:length].tobytes() 203 | self.view = self.view[length:] 204 | return val 205 | 206 | return b"" 207 | 208 | def read_short(self) -> int | None: 209 | return int.from_bytes(self.read_byte(2), "little", signed=True) 210 | 211 | def read_int(self) -> int: 212 | return int.from_bytes(self.read_byte(4), "little", signed=True) 213 | 214 | def read_uleb128(self) -> int: 215 | val = shift = 0 216 | 217 | while True: 218 | b = self.read_byte()[0] 219 | 220 | val |= (b & 127) << shift 221 | if (b & 128) == 0x00: 222 | break 223 | shift += 7 224 | 225 | return val 226 | 227 | def read_string(self) -> str: 228 | if self.read_byte() == 0x00: 229 | return "" 230 | 231 | return self.read_byte(length=self.read_uleb128()).decode() 232 | 233 | 234 | if __name__ == "__main__": 235 | replay = ReplayInfo.from_file("replay.osr") 236 | print(replay.mode) 237 | print(replay.max_combo) 238 | -------------------------------------------------------------------------------- /app/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import time 4 | from pathlib import Path 5 | from typing import Any 6 | 7 | import requests 8 | from PIL import Image 9 | 10 | from app.generation.common.vector import Vector2 11 | from app.objects import api 12 | from app.version import Version 13 | 14 | API_KEY_FILE: Path = Path.cwd() / "apikey.txt" 15 | CACHE_FOLDER: Path = Path.cwd() / ".cache" 16 | AVATAR_FOLDER: Path = CACHE_FOLDER / "avatar" 17 | 18 | 19 | def get_api_client() -> api.APIWrapper: 20 | return api.APIWrapper.from_file(API_KEY_FILE) 21 | 22 | 23 | def ensure_directories() -> int: 24 | for required_dir in [CACHE_FOLDER, AVATAR_FOLDER]: 25 | required_dir.mkdir(exist_ok=True, parents=True) 26 | 27 | return 0 28 | 29 | 30 | def ensure_default_assets() -> int: 31 | session: requests.Session = requests.Session() 32 | 33 | # Default 34 | default_assets_and_url: dict[str, str] = { 35 | "default_avatar.png": "https://a.ppy.sh/", 36 | "default_background.png": "https://assets.ppy.sh/contests/154/winners/Dreamxiety.png", 37 | "default_star.png": "https://raw.githubusercontent.com/xjunko/blobs/e1719872b7faad07b1b2400cea44055ce0051a71/osr2png/assets/default_star.png", 38 | "default_miss.png": "https://raw.githubusercontent.com/xjunko/blobs/e1719872b7faad07b1b2400cea44055ce0051a71/osr2png/assets/default_miss.png", 39 | "font.ttf": "https://raw.githubusercontent.com/xjunko/blobs/e1719872b7faad07b1b2400cea44055ce0051a71/osr2png/assets/font.ttf", 40 | } 41 | 42 | for filename, url in default_assets_and_url.items(): 43 | if not (file_path := CACHE_FOLDER / filename).exists(): 44 | print(f"[Startup] Getting default assets: {filename},", end="") 45 | 46 | # Download the motherfucking file 47 | with session.get(url) as res: 48 | if res.status_code != 200 and len(res.content) < 2048: 49 | print(" failed!") 50 | print( 51 | f"[Startup] Might want to put your own files in place there, `{file_path.resolve()}`.", 52 | ) 53 | 54 | print(" success!") 55 | file_path.write_bytes(res.content) 56 | 57 | return 0 58 | 59 | 60 | def ensure_up_to_date(current_version: Version) -> int: 61 | print(f"[Version] Current version: {current_version!r}") 62 | print(f"[Version] Checking github for a new version of osr2png,", end="") 63 | 64 | with requests.Session() as session: 65 | with session.get( 66 | "https://api.github.com/repos/xjunko/osr2png/releases/latest", 67 | ) as res: 68 | if res.status_code != 200: 69 | print(" failed!") 70 | return 0 71 | 72 | data: dict[Any, Any] = res.json() 73 | 74 | github_version = Version.from_str(data["tag_name"]) 75 | 76 | print(" success!") 77 | 78 | # Compare our version with github's 79 | if github_version > current_version: 80 | print("[Version] You're using an older version of osr2png.") 81 | print("[Version] You can update it from here:", data["html_url"]) 82 | time.sleep(3) 83 | else: 84 | print("[Version] You're using the latest version of osr2png.") 85 | 86 | return 0 87 | 88 | 89 | """ Image crap """ 90 | 91 | 92 | def resize_image_to_resolution_but_keep_ratio( 93 | img: Image.Image, 94 | resolution: Vector2, 95 | ) -> Image.Image: 96 | ratio = resolution.x / img.width 97 | 98 | return img.resize((int(img.width * ratio), int(img.height * ratio)), Image.LANCZOS) 99 | 100 | 101 | def get_player_avatar(name: str) -> Path: 102 | api_client: api.APIWrapper = get_api_client() 103 | session: requests.Session = requests.Session() 104 | 105 | if not (avatar_path := AVATAR_FOLDER / name).exists(): 106 | if not (user_id := api_client.get_player_id(name)): 107 | return CACHE_FOLDER / "default_avatar.png" 108 | 109 | # Download 110 | print(f"[API] Downloading {name}'s avatar,", end="") 111 | with session.get(f"https://a.ppy.sh/{user_id}") as avatar_res: 112 | if avatar_res.status_code != 200 and len(avatar_res.content) < 2000: 113 | print(" failed.") 114 | return CACHE_FOLDER / "default_avatar.png" 115 | 116 | print(" success!") 117 | avatar_path.write_bytes(avatar_res.content) 118 | 119 | return avatar_path 120 | -------------------------------------------------------------------------------- /app/version.py: -------------------------------------------------------------------------------- 1 | """ version.py - chad scuffed version class""" 2 | from __future__ import annotations 3 | 4 | 5 | class Version: 6 | major: int 7 | minor: int 8 | patch: int 9 | message: str | None 10 | 11 | def __init__(self) -> None: 12 | self.major = self.minor = self.patch = 0 13 | self.message = None 14 | 15 | def __repr__(self) -> str: 16 | return ( 17 | f"{self.major}.{self.minor}.{self.patch}" 18 | + ["", f" [{self.message}]"][len(self.message) > 0] # type: ignore 19 | ) 20 | 21 | def __gt__(self, other: Version) -> bool: 22 | return [self.major, self.minor, self.patch] > [ 23 | other.major, 24 | other.minor, 25 | other.patch, 26 | ] 27 | 28 | def __lt__(self, other: Version) -> bool: 29 | return [self.major, self.minor, self.patch] < [ 30 | other.major, 31 | other.minor, 32 | other.patch, 33 | ] 34 | 35 | @classmethod 36 | def from_str(cls, version_str: str) -> Version: 37 | version_raw, *message = version_str.split("|") 38 | 39 | major, minor, patch = version_raw.split(".") 40 | 41 | ver: Version = cls() 42 | ver.major = int(major) 43 | ver.minor = int(minor) 44 | ver.patch = int(patch) 45 | ver.message = "".join(message) 46 | 47 | return ver 48 | -------------------------------------------------------------------------------- /build.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import platform 5 | import sys 6 | from pathlib import Path 7 | 8 | from PyInstaller.__main__ import run as run_pyinstaller 9 | 10 | # 11 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) 12 | 13 | OS_NAME: str = sys.platform 14 | IS_LINUX: bool = OS_NAME == "linux" 15 | 16 | 17 | def main() -> int: 18 | output_folder: Path = Path.cwd() / "build" 19 | output_file: Path = output_folder / ("osr2png" + ["", ".exe"][not IS_LINUX]) 20 | 21 | print(f"Building osr2png for {OS_NAME} {platform.machine()}.") 22 | print(f"Final file: {output_file}") 23 | 24 | opts = [ 25 | f"--name=osr2png", 26 | "--upx-exclude=vcruntime140.dll", 27 | "--noconfirm", 28 | "--recursive-copy-metadata=ossapi", 29 | "--onefile", 30 | "main.py", 31 | ] 32 | 33 | print(f"Running PyInstaller with {opts}") 34 | run_pyinstaller(opts) 35 | 36 | return 0 37 | 38 | 39 | if __name__ == "__main__": 40 | raise SystemExit(main()) 41 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | """ 2 | main.py - the start of everything 3 | """ 4 | from __future__ import annotations 5 | 6 | __author__ = "xJunko" 7 | __discord__ = "xjunko" 8 | 9 | import argparse 10 | import sys 11 | from pathlib import Path 12 | 13 | import app.utils 14 | from app.gazo import Replay2Picture 15 | from app.generation.common import CanvasStyle, vector 16 | from app.version import Version 17 | 18 | # 19 | CURRENT_VERSION = Version.from_str("0.8.3") 20 | 21 | 22 | def main(argv: list[str]) -> int: 23 | """Ensure program is okay to run""" 24 | for early_task in [app.utils.ensure_directories, app.utils.ensure_default_assets]: 25 | if ret_code := early_task(): 26 | return ret_code 27 | 28 | """ command-line arguments """ 29 | parser = argparse.ArgumentParser( 30 | description="An open-source osu! thumbnail generator for lazy circle clickers.", 31 | ) 32 | 33 | # Info 34 | parser.add_argument( 35 | "-v", 36 | "--version", 37 | action="version", 38 | version=f"osr2png v{CURRENT_VERSION}", 39 | ) 40 | 41 | # Source 42 | parser.add_argument( 43 | "-r", 44 | "--replay", 45 | help="[Optional] The path of the .osr file", 46 | ) 47 | parser.add_argument( 48 | "-b", 49 | "--beatmap", 50 | help="[Optional] The path of the .osu file, if using a custom beatmap.", 51 | ) 52 | 53 | # Where to save 54 | parser.add_argument( 55 | "-o", 56 | "--output", 57 | help="[Optional] Change generated image filename.", 58 | ) 59 | 60 | # Image Gen 61 | parser.add_argument( 62 | "-m", 63 | "--message", 64 | help="[Optional] The extra text at the bottom", 65 | type=str, 66 | default="", 67 | ) 68 | 69 | parser.add_argument( 70 | "-s", 71 | "--style", 72 | help="Style of Image, [{}]".format( 73 | " ".join([f"{n.value}: {n.name}" for n in CanvasStyle]), 74 | ), 75 | type=int, 76 | default=1, 77 | ) 78 | parser.add_argument( 79 | "-width", 80 | "--width", 81 | help="[Optional] The width of the image.", 82 | type=int, 83 | default=1920, 84 | ) 85 | parser.add_argument( 86 | "-height", 87 | "--height", 88 | help="[Optional] The width of the image.", 89 | type=int, 90 | default=1080, 91 | ) 92 | 93 | parser.add_argument( 94 | "-dim", 95 | "--background-dim", 96 | help="[Optional] The dim of beatmap background.", 97 | type=float, 98 | default=0.6, 99 | ) 100 | 101 | parser.add_argument( 102 | "-blur", 103 | "--background-blur", 104 | help="[Optional] The blur of beatmap background.", 105 | type=float, 106 | default=5, 107 | ) 108 | 109 | parser.add_argument( 110 | "-border", 111 | "--background-border", 112 | help="[Optional] The border of beatmap background's dim.", 113 | type=float, 114 | default=25, 115 | ) 116 | 117 | # Misc options 118 | parser.add_argument( 119 | "-skip", 120 | "--skip-update", 121 | help="[Optional] Don't check for a new version on Github.", 122 | action="store_true", 123 | ) 124 | 125 | args = parser.parse_args() 126 | 127 | if not args.replay and not args.beatmap: 128 | parser.print_help() 129 | parser.error( 130 | "No argument passed, please give `.osr` or `.osu` file into the params.", 131 | ) 132 | 133 | if args.replay: 134 | # Check for update 135 | if not args.skip_update: 136 | try: 137 | app.utils.ensure_up_to_date(CURRENT_VERSION) 138 | except Exception as _: 139 | print("[Version] Failed to reached github.") 140 | 141 | replay_path: Path = Path(args.replay) 142 | beatmap_path: Path | None = None 143 | 144 | if args.beatmap: 145 | beatmap_path = Path(args.beatmap) 146 | 147 | replay = Replay2Picture.from_replay_file( 148 | replay_path=Path(replay_path), 149 | beatmap_file=beatmap_path, 150 | ) 151 | else: 152 | # Generate from beatmap file only, SS everything. 153 | # TODO: 154 | parser.error("Beatmap only ImageGen is not supported yet.") 155 | replay = Replay2Picture() 156 | 157 | # Common 158 | replay.calculate() 159 | replay.generate( 160 | style=args.style, 161 | resolution=vector.Vector2(x=args.width, y=args.height), # type: ignore 162 | background_blur=args.background_blur, 163 | background_dim=args.background_dim, 164 | background_border=args.background_border, 165 | message=args.message, 166 | custom_filename=args.output, 167 | ) 168 | 169 | return 0 170 | 171 | 172 | if __name__ == "__main__": 173 | raise SystemExit(main(sys.argv)) 174 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | rosu-pp-py 2 | Pillow 3 | requests 4 | pyinstaller 5 | ossapi 6 | --------------------------------------------------------------------------------