diff --git a/.gitignore b/.gitignore index 6e7a7c1..c58175e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # Dev environment *venv/ .vscode/ -.out/ +*out/ # python files __pycache__/ diff --git a/atlasimagecomposer/backend/atlas.py b/atlasimagecomposer/backend/atlas.py index fcdc3f9..40e9722 100644 --- a/atlasimagecomposer/backend/atlas.py +++ b/atlasimagecomposer/backend/atlas.py @@ -1,25 +1,63 @@ -from typing import List, Tuple, TypedDict - +import pathlib +from typing import Annotated, List, NotRequired, Tuple, TypedDict import requests -from ..config import AtlasDefaults, ExpressionDefaults, Paths - +from ..config import AtlasDefaults, Paths, ExpressionDefaults class SpritesheetData(TypedDict): - facesize: int + facesize: Tuple[int, int] position: Tuple[int, int] -class ReturnData(TypedDict): - id: str - faceX: int - faceY: int - extendData: dict[str, int] +class ExtendData(TypedDict): + faceSizeRect: NotRequired[Annotated[List[int], 2]] + faceSize: NotRequired[int] +def fetch_config(chara_id: str) -> SpritesheetData: + url = f"https://api.atlasacademy.io/raw/JP/svtScript?charaId={chara_id}" -def fetch_expression_sheets(servantid: int, imageid: str): + response = requests.get(url, timeout=AtlasDefaults.TIMEOUT) + if not response.ok: + raise ValueError(f"{response.status_code} - {response.text}") + + resp_data = response.json()[0] + extend_data: ExtendData = resp_data["extendData"] + + if "faceSizeRect" in extend_data: + facesize: Tuple[int, int] = tuple(extend_data["faceSizeRect"]) # type: ignore + else: + facesize = tuple(2 * [ extend_data.get("faceSize", ExpressionDefaults.FACESIZE) ]) # type: ignore + + position: tuple[int, int] = (resp_data["faceX"], resp_data["faceY"]) + + returndata: SpritesheetData = { + "facesize": facesize, + "position": position + } + + return returndata + +def fetch_mstsvtjson(): + url = AtlasDefaults.MST_SVT_JSON + filelocation = Paths.IMAGES / "mstsvt.json" + + if filelocation.exists(): + print("Found cached asset for mstsvt.json") + return + + with open(filelocation, 'wb') as handle: + response = requests.get(url, stream=True, timeout=AtlasDefaults.TIMEOUT) + status = response.status_code + if status != 200: + raise ValueError("Could not fetch mstsvnt.json from atlas - please check your network connection") + for block in response.iter_content(1024): + if not block: + break + handle.write(block) + +def fetch_expression_sheets(basefolder: str, imageid: str): atlasurl_base = f"https://static.atlasacademy.io/{AtlasDefaults.REGION}/CharaFigure/{imageid}" - savefolder = Paths.IMAGES / str(servantid) / imageid + savefolder = Paths.IMAGES / basefolder / str(imageid) if not savefolder.is_dir(): savefolder.mkdir(exist_ok=True, parents=True) @@ -56,33 +94,20 @@ def fetch_expression_sheets(servantid: int, imageid: str): p = savefolder / f"{idx}.png" p.unlink(missing_ok=True) + return savefolder -def fetch_info(servantid: int): - atlasurl = f"https://api.atlasacademy.io/basic/{AtlasDefaults.REGION}/servant/{servantid}?lang=en" + +def fetch_data(servantid: int) -> List[str]: + atlasurl = f"https://api.atlasacademy.io/nice/{AtlasDefaults.REGION}/servant/{servantid}?lore=false&lang=en" response = requests.get(atlasurl, timeout=AtlasDefaults.TIMEOUT) if not response.ok: - print(response) + raise ValueError(f"{response.status_code} - {response.text}") - data = response.json() - print(f"Fetching data and sprites for {data['name']} (ID: {servantid})") + responsedata = response.json() + svtname = responsedata["name"] + charascripts: List[dict[str, str]] = responsedata["charaScripts"] + chara_ids: List[str] = [chara["id"] for chara in charascripts] -def fetch_data(servantid: int) -> dict[str, SpritesheetData]: - fetch_info(servantid) - atlasurl = f"https://api.atlasacademy.io/raw/{AtlasDefaults.REGION}/servant/{servantid}?lore=false&expand=true&lang=en" - - response = requests.get(atlasurl, timeout=AtlasDefaults.TIMEOUT) - if not response.ok: - print(response) - - data: List[ReturnData] = response.json()["mstSvtScript"] - - spritesheet_data: dict[str, SpritesheetData] = { - str(spritesheet["id"]): { - "facesize": spritesheet["extendData"].get("faceSize", ExpressionDefaults.FACESIZE), - "position": (spritesheet["faceX"], spritesheet["faceY"]) - } for spritesheet in data - } - - print(f"Found a total of {len(spritesheet_data)} spritesheets") - return spritesheet_data + print(f"{svtname} ({servantid}) - {len(chara_ids)} charaIds") + return chara_ids diff --git a/atlasimagecomposer/backend/compose.py b/atlasimagecomposer/backend/compose.py index 7dfa989..5f2efe2 100644 --- a/atlasimagecomposer/backend/compose.py +++ b/atlasimagecomposer/backend/compose.py @@ -6,46 +6,45 @@ from PIL import Image from tqdm.contrib import itertools as tqdm_itertools from ..config import Paths -from .atlas import SpritesheetData, fetch_data, fetch_expression_sheets +from .atlas import SpritesheetData, fetch_data, fetch_expression_sheets, fetch_config - -def compose(servantid: int, filters: Optional[List[str]] = None): +def compose(input_id: int, filters: Optional[List[str]] = None): Paths.IMAGES.mkdir(exist_ok=True) Paths.OUTPUT.mkdir(exist_ok=True) - sprites = fetch_data(servantid) + if input_id < 10000: + chara_ids = fetch_data(input_id) + savefolder = Paths.OUTPUT / str(input_id) + else: + print(f"Processing manually uploaded charaId {input_id}") + savefolder = Paths.OUTPUT / "manual" + chara_ids = [input_id] + + if not savefolder.is_dir(): + savefolder.mkdir(parents=True, exist_ok=True) if filters is not None: - sprites = { key: value for key, value in sprites.items() if key in filters } + chara_ids = [ v for v in chara_ids if v in filters ] - for sprite in sprites: - fetch_expression_sheets(servantid, sprite) + for char_id in chara_ids: + expfolder = fetch_expression_sheets(savefolder.stem, char_id) + config = fetch_config(char_id) + process_sprite(expfolder, config, savefolder) - path = Paths.IMAGES / str(servantid) - - savefolder = Paths.OUTPUT / str(servantid) - if not savefolder.is_dir(): - savefolder.mkdir() - - for f in path.iterdir(): - if filters is not None and str(f.stem) not in filters: - continue - process_sprite(f, servantid, sprites[f.stem]) - - print(f"Files have been saved at: {(Paths.OUTPUT / str(servantid)).absolute()}") + print(f"Files have been saved at: {savefolder.absolute()}") -def calculate_counts(width: int, height: int, facesize: int): - return height // facesize, width // facesize +def calculate_counts(width: int, height: int, facesize: tuple[int, int]): + return height // facesize[1], width // facesize[0] +def gen_main_sprite(folder: pathlib.Path): + image = Image.open(folder / "0.png") + width, height = image.size + return image.crop((0, 0, width, height - 256)) -def process_sprite(images_folder: pathlib.Path, servantid: int, configdata: SpritesheetData): - main = Image.open(images_folder / "0.png") - - width, _ = main.size - main_sprite = main.crop((0, 0, width, 756)) - - save_sprite(main_sprite, servantid, f"{images_folder.stem}") +def process_sprite(images_folder: pathlib.Path, configdata: SpritesheetData, outputfolder: pathlib.Path): + main_sprite = gen_main_sprite(images_folder) + save_sprite(main_sprite, outputfolder, f"{images_folder.stem}") for i in images_folder.iterdir(): initial_row, index = 0, int(i.stem) @@ -53,22 +52,24 @@ def process_sprite(images_folder: pathlib.Path, servantid: int, configdata: Spri rowcount, colcount = calculate_counts(*expressions.size, configdata["facesize"]) - if i.name == "0.png": - initial_row = 3 + if i.name == "0.png" and 256 < configdata["facesize"][1]: + continue + elif i.name == "0.png": + initial_row = rowcount - 1 for x, y in tqdm_itertools.product(range(initial_row, rowcount), range(0, colcount), ascii="-="): img = generate_sprite(main_sprite, expressions, x, y, configdata) if img is not None: - save_sprite(img, servantid, f"{images_folder.stem}", (x, y, colcount, index)) + save_sprite(img, outputfolder, f"{images_folder.stem}", (x, y, colcount, index)) def generate_sprite(main_sprite: Image.Image, expressions: Image.Image, row: int, col: int, configdata: SpritesheetData) -> Image.Image | None: position, facesize = configdata["position"], configdata["facesize"] roi = ( - col * facesize, - row * facesize, - (col + 1) * facesize - 1, - (row + 1) * facesize - 1 + col * facesize[0], + row * facesize[1], + (col + 1) * facesize[0] - 1, + (row + 1) * facesize[1] - 1 ) expression = expressions.crop(roi) @@ -81,8 +82,8 @@ def generate_sprite(main_sprite: Image.Image, expressions: Image.Image, row: int return composition -def save_sprite(image: Image.Image, folder_id: int, name: str, info: tuple | None = None): - savefolder = Paths.OUTPUT / str(folder_id) / name +def save_sprite(image: Image.Image, outputfolder: pathlib.Path, name: str, info: tuple | None = None): + savefolder = outputfolder / name if not savefolder.is_dir(): savefolder.mkdir() @@ -97,6 +98,8 @@ def save_sprite(image: Image.Image, folder_id: int, name: str, info: tuple | Non raise ValueError("Should not have any faces") elif column_count == 4: postfix = str((column_count * row + col + 1) + pow(column_count, 2) * (file_idx - 1) + column_count) + elif column_count == 1: + postfix = str((file_idx - 1) * 16 + 1 if file_idx >= 2 else file_idx * 4 + 1) elif column_count < 4: postfix = str((column_count * row + col + 1) + pow(column_count, 2) * (file_idx - 1)) else: diff --git a/atlasimagecomposer/cli/cli.py b/atlasimagecomposer/cli/cli.py index 20c4059..34638be 100644 --- a/atlasimagecomposer/cli/cli.py +++ b/atlasimagecomposer/cli/cli.py @@ -56,9 +56,9 @@ def run_cli(): welcome() - servantid = input("Enter servant ID: ") + input_id = input("Enter servantId/charaId: ") try: - t = int(servantid) + t = int(input_id) if t <= 0: raise ValueError except ValueError: @@ -66,11 +66,13 @@ def run_cli(): sys.exit(1) if args.cacheclear: - cachepath = Paths.IMAGES / str(servantid) + cachepath = Paths.IMAGES / str(input_id) + if input_id > 10000: + cachepath = Paths.IMAGES / "manual" / str(input_id) if cachepath.exists(): rmdir(cachepath) print("Successfully cleared cached assets") else: print("No cache to clear was found, continuing") - compose(servantid, args.filter) + compose(int(input_id), args.filter) diff --git a/atlasimagecomposer/config/config.py b/atlasimagecomposer/config/config.py index 8d10a0d..30e51b3 100644 --- a/atlasimagecomposer/config/config.py +++ b/atlasimagecomposer/config/config.py @@ -14,3 +14,4 @@ class ExpressionDefaults: class AtlasDefaults: REGION = "JP" TIMEOUT = 10 + MST_SVT_JSON = "https://git.atlasacademy.io/atlasacademy/fgo-game-data/raw/branch/JP/master/mstSvtScript.json" diff --git a/pyproject.toml b/pyproject.toml index 4b00d5c..10c4b2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "atlasimagecomposer" -version = "0.1.0-c.1" +version = "0.1.0-c.2" dependencies = [ "numpy~=2.0.1", "pillow~=10.4.0", @@ -10,7 +10,7 @@ dependencies = [ requires-python = ">= 3.10" authors = [{name = "Firq", email = "firelp42@gmail.com"}] maintainers = [{name = "Firq", email = "firelp42@gmail.com"}] -description = "Package that enables peopßle to quickly download and generate all potential spritesheet expressions with a single command" +description = "Package that enables people to quickly download and generate all potential spritesheet expressions with a single command" classifiers = [ "Development Status :: 3 - Alpha", "Programming Language :: Python :: 3",