Rebranding
All checks were successful
/ pylint (push) Successful in 10s
/ mypy (push) Successful in 12s

This commit is contained in:
Firq 2024-10-16 22:13:39 +02:00
parent 62f36dbdc2
commit f3f4ec51d2
Signed by: Firq
GPG key ID: 3ACC61C8CEC83C20
20 changed files with 128 additions and 91 deletions

2
.gitignore vendored
View file

@ -1,7 +1,7 @@
# Dev environment # Dev environment
*venv/ *venv/
.vscode/ .vscode/
*out/ *out*/
# python files # python files
__pycache__/ __pycache__/

View file

@ -1,27 +1,54 @@
# atlasimagecomposer # skyeweave
A small CLI toolkit that allows you to easily download and compose any FGO expression sheets Easily generate any FGO expression sheets from an id
## Installations > Developed by [Firq](https://firq.dev/) and powered by the [AtlasAcademy API](https://atlasacademy.io/)
Install it using `pip` - You need to specify my extra index for it to be found ## Installation
The CLI can be installed by using `pip`. Python >= 3.10 is required.
```shell ```shell
pip install --extra-index-url https://forgejo.neshweb.net/api/packages/Firq/pypi/simple/ atlasimagecomposer pip install --extra-index-url https://forgejo.neshweb.net/api/packages/Firq/pypi/simple/ skyeweave
``` ```
Note: Specifying the additional index of `forgejo.neshweb.net` is necessary, as I don't host my packages on PyPI. Using `--extra-index-url` is necessary as the
specified depencencies are not hosted on that index.
## Usage ## Usage
Use it via the CLI command `atlasimagecomposer` `skyeweave` is a CLI application, meaning it needs to be accessed via the commandline.
The following options are available:
- `--help` / `-h`: Shows helpful information about the other arguments
- `--version`: Shows the version number
- `--output`: Sets the output file path. This can be a relative path (`./out` or an absolute path `C:/files/out`)
- `--id`: Specify a servantId or charaId. This will skip the user prompt afterwards (useful in CI applications)
- `--filter`: Specify which spritesheets will actually be used. Useful if you want multiple spritesheets, but not all
- `--timeout`: Sets the timeout for any requests towards the Atlas Academy API. The default is 10 seconds
- `--no-cache`: Clear cache for this id, keeping all other files
- `--reset`: Delete any already downloaded assets
- `--quiet` / `-q`: Mute the output and hide progress bars
Typical usage:
```plain ```plain
usage: atlasimagecomposer [-h] [--version] [--output OUTPUT] [--filter FILTER [FILTER ...]] [--timeout TIMEOUT] [--clear-cache] skyeweave --output out --id 70
```
options:
-h, --help show this help message and exit This would generate the expressions for Scathach (Servant Id 70) in the folder out, using subfolders to better separate the outputs of multiple runs.
--version show program's version number and exit
--output OUTPUT Set the output location. This can be an absolute or relative path ## Issues / Support
--filter FILTER [FILTER ...] Specify one or more spritesheet ids that will be fetched
--timeout TIMEOUT Set the timeout for all requests towards AtlasAcademy, default is 10s If there are any issues with `skyeweave`, feel free to reach out to me using the AtlasAcademy discord (`#dev-corner`) or create an issue in this repo.
--clear-cache Clear cached assets before downloading files for a servant
If you want to help me debug when an issue occurs, set the environment variable `SKYEWEAVE_STDOUT_LEVEL` to `debug` and send me a copy of the log
Example on Windows:
```shell
$env:SKYEWEAVE_STDOUT_LEVEL="debug"
skyeweave --output out --id 70 > log.log
``` ```

View file

@ -1,3 +0,0 @@
import importlib.metadata
__version__ = importlib.metadata.version(__package__ or "atlasimagecomposer")

View file

@ -1 +0,0 @@
from .config import AtlasDefaults, ExpressionDefaults, Logging, Paths

View file

@ -1,12 +1,12 @@
[project] [project]
name = "atlasimagecomposer" name = "skyeweave"
version = "0.1.0-c.5" version = "0.1.0-c.1"
requires-python = ">= 3.10" requires-python = ">= 3.10"
authors = [{name = "Firq", email = "firelp42@gmail.com"}] authors = [{name = "Firq", email = "firelp42@gmail.com"}]
maintainers = [{name = "Firq", email = "firelp42@gmail.com"}] maintainers = [{name = "Firq", email = "firelp42@gmail.com"}]
description = "Package that enables people to quickly download and generate all potential spritesheet expressions with a single command" description = "Easily generate any FGO expression sheets from an id"
classifiers = [ classifiers = [
"Development Status :: 3 - Alpha", "Development Status :: 5 - Production/Stable",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
@ -30,11 +30,11 @@ typing = [
] ]
[project.scripts] [project.scripts]
atlasimagecomposer = "atlasimagecomposer.cli.cli:run_cli" skyeweave = "skyeweave.cli:run"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["."] where = ["."]
include = ["atlasimagecomposer*"] include = ["skyeweave*"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
"*" = ["py.typed"] "*" = ["py.typed"]

3
skyeweave/__init__.py Normal file
View file

@ -0,0 +1,3 @@
import importlib.metadata
__version__ = importlib.metadata.version(__package__ or "skyeweave")

View file

@ -2,9 +2,9 @@ import logging
from typing import Annotated, List, NotRequired, Tuple, TypedDict from typing import Annotated, List, NotRequired, Tuple, TypedDict
import requests import requests
from ..config import AtlasDefaults, Paths, ExpressionDefaults, Logging from ..config import AtlasAPIConfig, PathConfig, ExpressionDefaults, LoggingConfig
LOGGER = logging.getLogger(Logging.NAME) LOGGER = logging.getLogger(LoggingConfig.NAME)
class SpritesheetData(TypedDict): class SpritesheetData(TypedDict):
facesize: Tuple[int, int] facesize: Tuple[int, int]
@ -18,7 +18,7 @@ def fetch_config(chara_id: str) -> SpritesheetData:
url = f"https://api.atlasacademy.io/raw/JP/svtScript?charaId={chara_id}" url = f"https://api.atlasacademy.io/raw/JP/svtScript?charaId={chara_id}"
LOGGER.debug(f"Loading data for {url}") LOGGER.debug(f"Loading data for {url}")
response = requests.get(url, timeout=AtlasDefaults.TIMEOUT) response = requests.get(url, timeout=AtlasAPIConfig.TIMEOUT)
LOGGER.debug(f"{response.status_code} - {response.text}") LOGGER.debug(f"{response.status_code} - {response.text}")
if not response.ok: if not response.ok:
raise ValueError() raise ValueError()
@ -42,8 +42,8 @@ def fetch_config(chara_id: str) -> SpritesheetData:
return returndata return returndata
def fetch_mstsvtjson(): def fetch_mstsvtjson():
url = AtlasDefaults.MST_SVT_JSON url = AtlasAPIConfig.MST_SVT_JSON
filelocation = Paths.IMAGES / "mstsvt.json" filelocation = PathConfig.IMAGES / "mstsvt.json"
if filelocation.exists(): if filelocation.exists():
LOGGER.info("Found cached asset for mstsvt.json") LOGGER.info("Found cached asset for mstsvt.json")
@ -51,7 +51,7 @@ def fetch_mstsvtjson():
LOGGER.debug(f"Loading data for {url}") LOGGER.debug(f"Loading data for {url}")
with open(filelocation, 'wb') as handle: with open(filelocation, 'wb') as handle:
response = requests.get(url, stream=True, timeout=AtlasDefaults.TIMEOUT) response = requests.get(url, stream=True, timeout=AtlasAPIConfig.TIMEOUT)
status = response.status_code status = response.status_code
LOGGER.debug(f"{response.status_code} - {response.text}") LOGGER.debug(f"{response.status_code} - {response.text}")
if status != 200: if status != 200:
@ -62,9 +62,9 @@ def fetch_mstsvtjson():
handle.write(block) handle.write(block)
def fetch_expression_sheets(basefolder: str, imageid: str): def fetch_expression_sheets(basefolder: str, imageid: str):
atlasurl_base = f"https://static.atlasacademy.io/{AtlasDefaults.REGION}/CharaFigure/{imageid}" atlasurl_base = f"https://static.atlasacademy.io/{AtlasAPIConfig.REGION}/CharaFigure/{imageid}"
savefolder = Paths.IMAGES / basefolder / str(imageid) savefolder = PathConfig.IMAGES / basefolder / str(imageid)
if not savefolder.is_dir(): if not savefolder.is_dir():
savefolder.mkdir(exist_ok=True, parents=True) savefolder.mkdir(exist_ok=True, parents=True)
@ -89,7 +89,7 @@ def fetch_expression_sheets(basefolder: str, imageid: str):
LOGGER.debug(f"Loading data for {atlasurl}") LOGGER.debug(f"Loading data for {atlasurl}")
with open(filelocation, 'wb') as handle: with open(filelocation, 'wb') as handle:
response = requests.get(atlasurl, stream=True, timeout=AtlasDefaults.TIMEOUT) response = requests.get(atlasurl, stream=True, timeout=AtlasAPIConfig.TIMEOUT)
status = response.status_code status = response.status_code
LOGGER.debug(f"{response.status_code} - {response.text}") LOGGER.debug(f"{response.status_code} - {response.text}")
if status != 200: if status != 200:
@ -107,10 +107,10 @@ def fetch_expression_sheets(basefolder: str, imageid: str):
def fetch_data(servantid: int) -> List[str]: def fetch_data(servantid: int) -> List[str]:
atlasurl = f"https://api.atlasacademy.io/nice/{AtlasDefaults.REGION}/servant/{servantid}?lore=false&lang=en" atlasurl = f"https://api.atlasacademy.io/nice/{AtlasAPIConfig.REGION}/servant/{servantid}?lore=false&lang=en"
LOGGER.debug(f"Loading data for {atlasurl}") LOGGER.debug(f"Loading data for {atlasurl}")
response = requests.get(atlasurl, timeout=AtlasDefaults.TIMEOUT) response = requests.get(atlasurl, timeout=AtlasAPIConfig.TIMEOUT)
LOGGER.debug(f"{response.status_code}") LOGGER.debug(f"{response.status_code}")
if not response.ok: if not response.ok:
LOGGER.debug(f"{response.status_code} - {response.text}") LOGGER.debug(f"{response.status_code} - {response.text}")

View file

@ -6,21 +6,21 @@ from collections import Counter
from PIL import Image from PIL import Image
from tqdm.contrib import itertools as tqdm_itertools from tqdm.contrib import itertools as tqdm_itertools
from ..config import Paths, Logging from ..config import PathConfig, LoggingConfig
from .atlas import SpritesheetData, fetch_data, fetch_expression_sheets, fetch_config from .atlas import SpritesheetData, fetch_data, fetch_expression_sheets, fetch_config
LOGGER = logging.getLogger(Logging.NAME) LOGGER = logging.getLogger(LoggingConfig.NAME)
def compose(input_id: int, filters: Optional[List[str]] = None): def compose(input_id: int, filters: Optional[List[str]] = None):
Paths.IMAGES.mkdir(exist_ok=True) PathConfig.IMAGES.mkdir(exist_ok=True)
Paths.OUTPUT.mkdir(exist_ok=True) PathConfig.OUTPUT.mkdir(exist_ok=True)
if input_id < 10000: if input_id < 10000:
chara_ids = fetch_data(input_id) chara_ids = fetch_data(input_id)
savefolder = Paths.OUTPUT / str(input_id) savefolder = PathConfig.OUTPUT / str(input_id)
else: else:
LOGGER.info(f"Processing manually uploaded charaId {input_id}") LOGGER.info(f"Processing manually uploaded charaId {input_id}")
savefolder = Paths.OUTPUT / "manual" savefolder = PathConfig.OUTPUT / "manual"
chara_ids = [str(input_id)] chara_ids = [str(input_id)]
if not savefolder.is_dir(): if not savefolder.is_dir():
@ -67,7 +67,7 @@ def process_sprite(images_folder: pathlib.Path, configdata: SpritesheetData, out
if i.name == "0.png": if i.name == "0.png":
initial_row = rowcount - 1 initial_row = rowcount - 1
for x, y in tqdm_itertools.product(range(initial_row, rowcount), range(0, colcount), ascii="-=", desc=f"[PROG] [{Logging.NAME}]", bar_format="{desc} {percentage:3.0f}% |{bar}|"): for x, y in tqdm_itertools.product(range(initial_row, rowcount), range(0, colcount), ascii="-=", desc=f"[PROG] [{LoggingConfig.NAME}]", bar_format="{desc} {percentage:3.0f}% |{bar}|"):
img = generate_sprite(main_sprite, expressions, x, y, configdata) img = generate_sprite(main_sprite, expressions, x, y, configdata)
if img is not None: if img is not None:
image_idx = save_sprite(img, outputfolder, f"{images_folder.stem}", image_idx) image_idx = save_sprite(img, outputfolder, f"{images_folder.stem}", image_idx)
@ -107,8 +107,10 @@ def save_sprite(image: Image.Image, outputfolder: pathlib.Path, name: str, idx:
return idx + 1 return idx + 1
def is_empty(img: Image.Image): def is_empty(img: Image.Image):
data = Counter(img.crop((96, 96, 160, 160)).convert('LA').getdata()) w, h = img.size
croparea = (w * 0.375, h * 0.375, w * 0.625, h * 0.625 )
data = Counter(img.crop(croparea).convert('LA').getdata())
LOGGER.debug(f"Counts: {len(data)}") LOGGER.debug(f"Counts: {len(data)}")
if len(data) < 10: if len(data) < 6:
return True return True
return False return False

View file

@ -0,0 +1 @@
from .cli import run

View file

@ -6,11 +6,11 @@ from typing import List
from .. import __version__ from .. import __version__
from ..backend import compose from ..backend import compose
from ..config import Paths, AtlasDefaults, Logging from ..config import AtlasAPIConfig, LoggingConfig, PathConfig
from ..utils.filesystem import rmdir from ..utils.filesystem import rmdir
from ..utils.disables import disable_tqdm from ..utils.disables import disable_tqdm
LOGGER = logging.getLogger(Logging.NAME) LOGGER = logging.getLogger(LoggingConfig.NAME)
# pylint: disable=too-few-public-methods # pylint: disable=too-few-public-methods
class Arguments(argparse.Namespace): class Arguments(argparse.Namespace):
@ -18,8 +18,9 @@ class Arguments(argparse.Namespace):
Default Arguments when calling the CLI Default Arguments when calling the CLI
""" """
output: str output: str
id: str id: int
cacheclear: bool reset: bool
nocache: bool
filter: List[str] filter: List[str]
timeout: int timeout: int
quiet: bool quiet: bool
@ -30,17 +31,18 @@ def parse_arguments():
Returns arguments and extra arguments separately Returns arguments and extra arguments separately
""" """
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="atlasimagecomposer", prog="skyeweave",
description="CLI tool to automatically generate expression sheets for servants", description="CLI tool to automatically generate expression sheets for servants",
epilog="If there are any issues during execution, it helps to clear the cache from time to time") epilog="If there are any issues during execution, it helps to clear the cache from time to time")
parser.add_argument("--version", action="version", version=f"atlasimagecomposer {__version__}") parser.add_argument("--version", action="version", version=f"skyeweave {__version__}")
parser.add_argument("--output", action="store", type=str, default=None, dest="output", help="Set the output location. This can be an absolute or relative path") parser.add_argument("--output", action="store", type=str, default=None, dest="output", help="Set the output location. This can be an absolute or relative path")
parser.add_argument("--id", action="store", type=str, default=None, dest="id", help="Set the servantId/charaId - Skips user prompt when provided") parser.add_argument("--id", action="store", type=int, default=None, dest="id", help="Set the servantId/charaId - Skips user prompt when provided")
parser.add_argument("--filter", action="extend", nargs="+", dest="filter", help='Specify one or more spritesheet ids that will be fetched') parser.add_argument("--filter", action="extend", nargs="+", dest="filter", help='SSpecify which spritesheets will actually be used (one or more charaIds)')
parser.add_argument("--timeout", action="store", type=int, default=None, dest="timeout", help="Set the timeout for all requests towards AtlasAcademy, default is 10s") parser.add_argument("--timeout", action="store", type=int, default=None, dest="timeout", help="Set the timeout for all requests towards AtlasAcademy (default: 10s)")
parser.add_argument("--clear-cache", action="store_true", default=False, dest="cacheclear", help="Clear cached assets before downloading files") parser.add_argument("--no-cache", action="store_true", default=False, dest="nocache", help="Clear cache for this id, keeping all other files")
parser.add_argument("--quiet", "-q", action="store_true", default=False, dest="quiet", help="Disable logging output") parser.add_argument("--reset", action="store_true", default=False, dest="reset", help="Delete any already downloaded assets")
parser.add_argument("--quiet", "-q", action="store_true", default=False, dest="quiet", help="Mute output and hide progress bars")
args = Arguments() args = Arguments()
@ -48,18 +50,30 @@ def parse_arguments():
return args, extra_args return args, extra_args
def welcome(): def welcome():
print("-------------------------------------------------") print("-------------------------------------------")
print(" Welcome to the FGO Sprite loader and composer ") print(" Welcome to skyeweave, an expression sheet ")
print(" developed by Firq ") print(" composer developed by Firq ")
print("-------------------------------------------------") print("-------------------------------------------")
def run_cli(): def validate_id(input_id: None | str) -> int:
input_id = input_id or input("Enter servantId or charaId: ")
try:
if int(input_id) <= 0:
raise ValueError
except ValueError:
LOGGER.error("Servant ID has to be a valid integer above 0")
sys.exit(1)
return int(input_id)
def run():
args, _ = parse_arguments() args, _ = parse_arguments()
if args.output: if args.output:
Paths.OUTPUT = pathlib.Path(args.output) PathConfig.OUTPUT = pathlib.Path(args.output)
if args.timeout and args.timeout >= 0: if args.timeout and args.timeout >= 0:
AtlasDefaults.TIMEOUT = args.timeout AtlasAPIConfig.TIMEOUT = args.timeout
if args.quiet: if args.quiet:
disable_tqdm() disable_tqdm()
@ -67,28 +81,21 @@ def run_cli():
else: else:
welcome() welcome()
input_id = args.id input_id = validate_id(args.id)
if not input_id:
input_id = input("Enter servantId/charaId: ")
try: if args.nocache:
t = int(input_id) cachepath = PathConfig.IMAGES / str(input_id)
if t <= 0:
raise ValueError
except ValueError:
LOGGER.error("Servant ID has to be a valid integer above 0")
sys.exit(1)
input_id = int(input_id)
if args.cacheclear:
cachepath = Paths.IMAGES / str(input_id)
if input_id > 10000: if input_id > 10000:
cachepath = Paths.IMAGES / "manual" / str(input_id) cachepath = PathConfig.IMAGES / "manual" / str(input_id)
if cachepath.exists(): if cachepath.exists():
rmdir(cachepath) rmdir(cachepath)
LOGGER.info("Successfully cleared cached assets") LOGGER.info("Successfully cleared cached assets")
else: else:
LOGGER.info("No cache to clear was found, continuing") LOGGER.info("No cache to clear was found, continuing")
if args.reset:
if PathConfig.IMAGES.exists():
rmdir(PathConfig.IMAGES)
LOGGER.info("Successfully reset local storage")
compose(input_id, args.filter) compose(input_id, args.filter)

View file

@ -0,0 +1 @@
from .config import AtlasAPIConfig, ExpressionDefaults, LoggingConfig, PathConfig

View file

@ -2,21 +2,21 @@
import os import os
import pathlib import pathlib
class Logging: class LoggingConfig:
_level = os.environ.get("AIC_STDOUT_LEVEL", "info") _level = os.environ.get("SKYEWEAVE_STDOUT_LEVEL", "info")
LEVEL = int(_level) if _level.isdigit() else _level.upper() LEVEL = int(_level) if _level.isdigit() else _level.upper()
NAME = "atlasimagecomposer" NAME = "skyeweave"
class Paths: class PathConfig:
_root = pathlib.Path(__file__).parents[1] _root = pathlib.Path(__file__).parents[1]
IMAGES = _root / ".temp" IMAGES = _root / ".temp"
OUTPUT = _root / ".out" OUTPUT = pathlib.Path.cwd() / "output"
class ExpressionDefaults: class ExpressionDefaults:
FACESIZE = 256 FACESIZE = 256
SHEETSIZE = 1024 SHEETSIZE = 1024
class AtlasDefaults: class AtlasAPIConfig:
REGION = "JP" REGION = "JP"
TIMEOUT = 10 TIMEOUT = 10
MST_SVT_JSON = "https://git.atlasacademy.io/atlasacademy/fgo-game-data/raw/branch/JP/master/mstSvtScript.json" MST_SVT_JSON = "https://git.atlasacademy.io/atlasacademy/fgo-game-data/raw/branch/JP/master/mstSvtScript.json"

View file

@ -1,3 +1,4 @@
# pylint: disable=import-outside-toplevel
def disable_tqdm(): def disable_tqdm():
from tqdm import tqdm from tqdm import tqdm
from functools import partialmethod from functools import partialmethod

View file

@ -1,6 +1,5 @@
import pathlib import pathlib
def rmdir(directory: pathlib.Path): def rmdir(directory: pathlib.Path):
""" """
Recursively deletes all files and folders in a given directory Recursively deletes all files and folders in a given directory

View file

@ -1,15 +1,15 @@
import logging import logging
import sys import sys
from ..config import Logging from ..config import LoggingConfig
from .disables import disable_tqdm from .disables import disable_tqdm
def init_logger(): def init_logger():
if Logging.LEVEL == "DEBUG": if LoggingConfig.LEVEL == "DEBUG":
disable_tqdm() disable_tqdm()
logger = logging.getLogger(Logging.NAME) logger = logging.getLogger(LoggingConfig.NAME)
logger.setLevel(Logging.LEVEL) logger.setLevel(LoggingConfig.LEVEL)
handler = logging.StreamHandler(stream=sys.stdout) handler = logging.StreamHandler(stream=sys.stdout)
formatter = logging.Formatter('[%(levelname)s] [%(name)s] %(message)s') formatter = logging.Formatter('[%(levelname)s] [%(name)s] %(message)s')
handler.setFormatter(formatter) handler.setFormatter(formatter)