Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ cache
.env
*.sqlite3
art/dist/
__pycache__
data/shares/
117 changes: 111 additions & 6 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
render_template,
request,
send_file,
send_from_directory,
jsonify,
redirect,
url_for,
Expand All @@ -22,7 +23,11 @@
from functools import wraps

from app.utils.species_lookup import load_species_data
from app.utils.calculate_heights import calculate_height_offset, convert_to_inches
from app.utils.calculate_heights import (
calculate_height_offset,
convert_to_inches,
inches_to_feet_inches,
)
from app.utils.parse_data import (
extract_characters,
filter_valid_characters,
Expand All @@ -32,8 +37,12 @@
get_default_characters,
)
from app.utils.stats import StatsManager
from app.utils.generate_image import render_image
from app.utils.generate_image import render_image, get_dist_art_path
from app.utils.art_paths import layer_asset_urls
from app.utils.character import Character
from app.utils.taur_data import load_taur_data
from app.shares import register_share_routes
from app.shares.storage import share_id_from_request_args, taur_preview_exists

app = Flask(__name__)
app.secret_key = os.urandom(24)
Expand Down Expand Up @@ -81,6 +90,44 @@ def wrapped(*args, **kwargs):
]


TAUR_SPECIES_NAMES = [
s
for s in species_list
if s not in ["taur_(generic)", "preset_species", "rexouium"]
]

TAUR_DATA = load_taur_data()
TAUR_CALCULATOR_DIR = os.path.join(os.path.dirname(__file__), "taur_calculator")


def register_taur_calculator_routes(application):
if "taur_calculator_asset" in application.view_functions:
return

@application.route(
"/taur-calculator/<path:filename>", endpoint="taur_calculator_asset"
)
def taur_calculator_asset(filename):
return send_from_directory(TAUR_CALCULATOR_DIR, filename)


register_taur_calculator_routes(app)
register_share_routes(app, species_names=TAUR_SPECIES_NAMES)


@app.route("/art/<path:rel_path>")
def serve_art(rel_path):
"""Serve trimmed art assets from art/dist/."""
file_path = get_dist_art_path(rel_path)
art_dist_root = os.path.abspath(os.path.join("art", "dist"))
resolved = os.path.abspath(file_path)

if not resolved.startswith(art_dist_root + os.sep) or not os.path.isfile(resolved):
return "Not found", 404

return send_file(resolved, max_age=31536000)


@app.route("/generate-image")
@cache_with_stats(timeout=31536000, query_string=True)
def generate_image():
Expand Down Expand Up @@ -300,15 +347,73 @@ def add_preset():
return redirect(f"/?characters={characters_query}{settings_query}")


@app.route("/taur")
@app.route("/taur", methods=["GET"])
def taur():
"""
Base route for volnar's sub-page!
"""
Taur calculator!

return render_template("taur.html")
Collaboration with Volnar <3
"""
# Filter out some ones we dont want to show/dont have data
filtered_species = TAUR_SPECIES_NAMES

# Load species data for auto-population
species_data_map = {}
for species_name in filtered_species:
try:
data = load_species_data(species_name)
# Extract species_length, species_tail_length, species_weight from male section
# and get a default species_height from the first data point
if "male" in data:
male_data = data["male"]
species_data_map[species_name] = {
"species_length": male_data.get("species_length", 0),
"species_tail_length": male_data.get("species_tail_length", 0),
"species_weight": male_data.get("species_weight", 0),
"species_height": (
male_data.get("data", [{}])[0].get("height", 0)
if male_data.get("data")
else 0
),
}
except Exception as e:
logging.warning(f"Failed to load species data for {species_name}: {e}")
species_data_map[species_name] = {
"species_length": 0,
"species_tail_length": 0,
"species_weight": 0,
"species_height": 0,
}

taur_data = TAUR_DATA
share_id = share_id_from_request_args(
request.args,
species_names=set(filtered_species),
)
preview_url = (
url_for("serve_taur_share", share_id=share_id, _external=True)
if share_id and taur_preview_exists(share_id)
else None
)
return render_template(
"taur.html",
species=filtered_species,
species_data=species_data_map,
taur_data=taur_data,
taur_layer_urls={
key: layer_asset_urls(
layer["path"],
lambda path: url_for("serve_art", rel_path=path),
)
for key, layer in taur_data["layers"].items()
},
debug=app.debug or os.getenv("GIT_COMMIT") is None,
preview_url=preview_url,
)


# For WSGI
def create_app():
register_taur_calculator_routes(app)
register_share_routes(app, species_names=TAUR_SPECIES_NAMES)
return app
5 changes: 5 additions & 0 deletions app/shares/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Share previews!"""

from app.shares.routes import register_share_routes

__all__ = ["register_share_routes"]
91 changes: 91 additions & 0 deletions app/shares/image_checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import io

import numpy as np
from PIL import Image

from app.shares.storage import TAUR_EXPORT_HEIGHT, TAUR_EXPORT_WIDTH

MAX_PNG_BYTES = 2 * 1024 * 1024
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"


def validate_taur_png(data: bytes) -> tuple[Image.Image | None, str | None]:
if len(data) > MAX_PNG_BYTES:
return None, "image too large"

if not data.startswith(PNG_MAGIC):
return None, "not a PNG"

try:
image = Image.open(io.BytesIO(data))
image.load()
except Exception:
return None, "invalid PNG"

if image.format != "PNG":
return None, "not a PNG"

if image.mode == "RGBA":
background = Image.new("RGB", image.size, (255, 255, 255))
background.paste(image, mask=image.split()[3])
image = background
elif image.mode != "RGB":
image = image.convert("RGB")

err = _looks_like_taur_render(image)
if err:
return None, err

return image, None


def _looks_like_taur_render(image: Image.Image) -> str | None:
"""
Rejects arbitrary uploads, kinda not so smart :<
"""
width, height = image.size
if width != TAUR_EXPORT_WIDTH or height != TAUR_EXPORT_HEIGHT:
return "unexpected image dimensions"

arr = np.asarray(image, dtype=np.uint8)
if arr.ndim != 3 or arr.shape[2] != 3:
return "unexpected image format"

if not _corners_are_white(arr):
return "image does not look like a calculator render"

center_ratio = _non_white_ratio(_center_crop(arr, 0.55))
if center_ratio < 0.04:
return "image has no figure content"

full_ratio = _non_white_ratio(arr)
if full_ratio > 0.72:
return "image is too full for a calculator render"

return None


def _corners_are_white(arr: np.ndarray, *, min_mean: float = 228.0) -> bool:
h, w, _ = arr.shape
patch = max(10, min(h, w) // 18)
corners = (
arr[0:patch, 0:patch],
arr[0:patch, w - patch : w],
arr[h - patch : h, 0:patch],
arr[h - patch : h, w - patch : w],
)
return all(float(region.mean()) >= min_mean for region in corners)


def _center_crop(arr: np.ndarray, fraction: float) -> np.ndarray:
h, w, _ = arr.shape
crop_h = max(1, int(h * fraction))
crop_w = max(1, int(w * fraction))
y0 = (h - crop_h) // 2
x0 = (w - crop_w) // 2
return arr[y0 : y0 + crop_h, x0 : x0 + crop_w]


def _non_white_ratio(arr: np.ndarray, *, threshold: int = 238) -> float:
white = np.all(arr >= threshold, axis=2)
return float(1.0 - white.mean())
76 changes: 76 additions & 0 deletions app/shares/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from urllib.parse import parse_qsl

from flask import jsonify, request, send_file
from werkzeug.datastructures import MultiDict

from app.shares.image_checks import validate_taur_png
from app.shares.storage import (
canonicalize_query,
check_rate_limit,
save_taur_preview,
share_id_for_query,
taur_png_path,
)
from app.shares.taur_params import validate_taur_params


def register_share_routes(app, *, species_names: list[str] | None = None):
if "upload_taur_share" in app.view_functions:
return

species_set = set(species_names or [])

@app.route("/api/shares/taur", methods=["POST"])
def upload_taur_share():
client_ip = request.headers.get("X-Real-IP", request.remote_addr)
if not check_rate_limit(client_ip):
return jsonify({"error": "rate limit exceeded"}), 429

query_string = (request.form.get("query") or "").strip().lstrip("?")
if not query_string:
return jsonify({"error": "missing query"}), 400

pairs, err = validate_taur_params(
MultiDict(parse_qsl(query_string)),
species_set,
)
if err:
return jsonify({"error": err}), 400

upload = request.files.get("image")
if upload is None:
return jsonify({"error": "missing image"}), 400

png_bytes = upload.read()
image, err = validate_taur_png(png_bytes)
if err:
return jsonify({"error": err}), 400

canonical = canonicalize_query(pairs)
share_id = share_id_for_query(canonical)
save_taur_preview(share_id, png_bytes)

return jsonify({
"share_id": share_id,
"preview_url": f"/shares/taur/{share_id}.png",
"width": image.size[0],
"height": image.size[1],
})

@app.route("/shares/taur/<share_id>.png", methods=["GET"])
def serve_taur_share(share_id):
try:
path = taur_png_path(share_id)
except ValueError:
return "Not found", 404

if not path.is_file():
return "Not found", 404

response = send_file(
path,
mimetype="image/png",
max_age=31536000,
)
response.headers["Cache-Control"] = "public, max-age=31536000"
return response
Loading
Loading