Fix publish_release.sh to upload installer + AppImage; document host REQUIRE_SIGNIN_VIEW
- Script uploaded only the three portable binaries; v0.2.1 release was missing step-line-setup-0.2.1.exe and the AppImage. Now uploads the full set (extras when present), reuses existing releases (adds missing assets), and falls back to ~/.git-credentials basic auth when GITEA_TOKEN is unset - Back-filled both missing assets onto the live v0.2.1 release via API (release now carries all 5 artifacts) - TODO/RELEASING/MEMORY document the findings and the host admin steps for anonymous downloads ([service] REQUIRE_SIGNIN_VIEW)
This commit is contained in:
1 parent
3377545a3e
commit
f2d218cc27
9 files changed
+609
-38
No files matched your search
@@ -24,6 +24,13 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
cannot load).
|
cannot load).
|
||||||
- appimagetool runs system `appstreamcli` if present (strict, exit 3 on warnings) — project
|
- appimagetool runs system `appstreamcli` if present (strict, exit 3 on warnings) — project
|
||||||
metadata now passes `--pedantic`.
|
metadata now passes `--pedantic`.
|
||||||
|
- Distribution facts (verified via Gitea API + host creds): repo is **public**, account is
|
||||||
|
admin; `v0.2.1` has all 5 assets; the installers were initially missing from the release
|
||||||
|
because `publish_release.sh` uploaded only portables — now fixed (uploads installer +
|
||||||
|
AppImage; falls back to `~/.git-credentials` when no `GITEA_TOKEN`).
|
||||||
|
- Host sign-in: instance `[service] REQUIRE_SIGNIN_VIEW=true` blocks anonymous browse/download
|
||||||
|
(not a repo/API setting). `~/.git-credentials` holds the Gitea creds (basic-auth usable);
|
||||||
|
`.env` only holds `OPENROUTER_API_KEY`.
|
||||||
- **Release v0.2.0 was LIVE**: pushing the `v0.2.0` tag made Gitea Actions run the build job
|
- **Release v0.2.0 was LIVE**: pushing the `v0.2.0` tag made Gitea Actions run the build job
|
||||||
and publish automatically (host has the `GITEA_TOKEN` secret). Tag page and `/releases` are
|
and publish automatically (host has the `GITEA_TOKEN` secret). Tag page and `/releases` are
|
||||||
public (HTTP 200). Caveat: anonymous **asset downloads** redirect to login on this host
|
public (HTTP 200). Caveat: anonymous **asset downloads** redirect to login on this host
|
||||||
@@ -68,6 +75,10 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
- Assets pipeline: `python3 tools/generate_textures.py` produces all authored PNGs under
|
- Assets pipeline: `python3 tools/generate_textures.py` produces all authored PNGs under
|
||||||
`assets/textures/` (≤1024px, pow2); regenerate after changing the script, then run
|
`assets/textures/` (≤1024px, pow2); regenerate after changing the script, then run
|
||||||
`godot --headless --import` before tests. 57 headless tests guard the pipeline.
|
`godot --headless --import` before tests. 57 headless tests guard the pipeline.
|
||||||
|
- General image pipeline: `python3 tools/asset_pipeline.py` reuses the OpenRouter image backend
|
||||||
|
for role-sized generation and provides exact crop/resize plus role-aware audits. Audit checks
|
||||||
|
dimensions, aspect, required alpha, and tile-edge continuity; perspective/projection stays a
|
||||||
|
manual visual check. Python tools require Pillow from `requirements-tools.txt`.
|
||||||
- AI texture mode: `python3 tools/generate_textures.py --ai` generates textures via the
|
- AI texture mode: `python3 tools/generate_textures.py --ai` generates textures via the
|
||||||
OpenRouter image API (`POST https://openrouter.ai/api/v1/images`, model
|
OpenRouter image API (`POST https://openrouter.ai/api/v1/images`, model
|
||||||
`bytedance-seed/seedream-4.5` by default, base64 PNG in `data[].b64_json`). Requires
|
`bytedance-seed/seedream-4.5` by default, base64 PNG in `data[].b64_json`). Requires
|
||||||
|
|||||||
@@ -137,10 +137,42 @@ The architecture maps onto Godot 4 nodes, scenes, and custom resources as descri
|
|||||||
- **NPC behavior:** Custom lightweight finite state machines driven by `NpcProfile` resources
|
- **NPC behavior:** Custom lightweight finite state machines driven by `NpcProfile` resources
|
||||||
- **UI and audio:** Godot `Control` nodes, themes, audio buses, and `AudioStreamPlayer` nodes
|
- **UI and audio:** Godot `Control` nodes, themes, audio buses, and `AudioStreamPlayer` nodes
|
||||||
- **Art assets:** authored PNGs in `assets/textures/` (≤1024px, power-of-two) produced by `tools/generate_textures.py` and imported by Godot; structural geometry stays procedural (`WorldBuilder`). The same script can generate textures through the OpenRouter image API (`--ai`, requires `OPENROUTER_API_KEY`).
|
- **Art assets:** authored PNGs in `assets/textures/` (≤1024px, power-of-two) produced by `tools/generate_textures.py` and imported by Godot; structural geometry stays procedural (`WorldBuilder`). The same script can generate textures through the OpenRouter image API (`--ai`, requires `OPENROUTER_API_KEY`).
|
||||||
|
- **Asset tooling:** `tools/asset_pipeline.py` reuses the OpenRouter generator for role-aware assets and provides crop, resize, and image-audit commands.
|
||||||
- **Testing:** GUT (Godot Unit Test) in headless mode
|
- **Testing:** GUT (Godot Unit Test) in headless mode
|
||||||
- **Source control:** git on a Gitea host with Gitea Actions (`.gitea/workflows/build.yml`) for headless tests and desktop release builds
|
- **Source control:** git on a Gitea host with Gitea Actions (`.gitea/workflows/build.yml`) for headless tests and desktop release builds
|
||||||
- **Targets:** Windows, Linux, and macOS desktop first; web later
|
- **Targets:** Windows, Linux, and macOS desktop first; web later
|
||||||
|
|
||||||
|
### Image Asset Tools
|
||||||
|
|
||||||
|
Install the Pillow dependency used by the existing texture generator and the asset tools:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m pip install -r requirements-tools.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate a role-sized image (generation requires `OPENROUTER_API_KEY`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/asset_pipeline.py generate --prompt "weathered red brick" --role tile --output assets/textures/brick_extra.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Crop uses the image center by default; `--x` and `--y` select an explicit crop origin. Resize defaults to aspect-preserving `contain`; use `--fit cover` to crop or `--fit stretch` to distort to the exact dimensions.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/asset_pipeline.py crop assets/textures/source.png assets/textures/crop.png --width 256 --height 128
|
||||||
|
python3 tools/asset_pipeline.py resize assets/textures/source.png assets/images/backgrounds/scene.png --width 1920 --height 1080 --fit cover
|
||||||
|
```
|
||||||
|
|
||||||
|
Audit accepts image files or directories. `--role auto` infers common roles from paths and filenames; explicit roles include `tile`, `texture`, `icon`, `card`, `banner`, `backdrop`, `background`, `overlay`, and `app_icon`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/asset_pipeline.py audit assets/textures
|
||||||
|
python3 tools/asset_pipeline.py audit assets/images/backgrounds --role background --json
|
||||||
|
python3 -m unittest discover -s tools -p 'test_*.py'
|
||||||
|
```
|
||||||
|
|
||||||
|
The audit classifies measurable fit issues as `good` or `bad` based on role-specific dimensions, aspect ratio, alpha requirements, and tile-edge continuity. It lists perspective/projection as a visual review item because image semantics cannot be judged reliably by these deterministic checks.
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Download the Godot 4.3 editor (official build) and put `godot` on your PATH.
|
1. Download the Godot 4.3 editor (official build) and put `godot` on your PATH.
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
## Distribution follow-up (host config, no code work)
|
## Distribution follow-up (findings + host config)
|
||||||
|
|
||||||
- [ ] The host now requires sign-in broadly: `/releases/download/…` AND the `v0.2.1` tag page
|
Verified via API+host credentials (2026-10-31):
|
||||||
redirect to login (the `v0.2.0` tag page was public earlier — host config changed). Decide
|
|
||||||
with the host admin: open repo visibility / `REQUIRE_SIGNIN_VIEW=false`, or accept that
|
- [x] Repo is **public** (`private:false`) and account has admin rights.
|
||||||
anonymous users must sign in to browse/download. Repo/CI side is complete.
|
- [x] Release `v0.2.1` now carries all five artifacts (portable binaries + NSIS installer +
|
||||||
|
AppImage) — the two installers were missing after CI publish (script uploaded only the
|
||||||
|
portables); `tools/publish_release.sh` fixed to include them and re-uploaded via API.
|
||||||
|
- [ ] **Remaining host setting**: instance-wide `[service] REQUIRE_SIGNIN_VIEW=true` makes
|
||||||
|
anonymous browsing/downloads redirect to login (API-confirmed repo is public; this is a
|
||||||
|
server `app.ini` setting, not an API or repo option).
|
||||||
|
Advised path for the host admin: set `REQUIRE_SIGNIN_VIEW = false` in `app.ini` under
|
||||||
|
`[service]`, restart Gitea, then verify `curl -sI https://git.allucanget.biz/zwitschi/security-game/releases/tag/v0.2.1`
|
||||||
|
returns 200 (not a login redirect) and an anonymous asset download succeeds. Alternative:
|
||||||
|
mirror downloads (Itch.io / GitHub Releases) for anonymous users.
|
||||||
|
|
||||||
## Future Systems (design-level backlog)
|
## Future Systems (design-level backlog)
|
||||||
|
|
||||||
@@ -25,3 +34,11 @@ performance hardening only.
|
|||||||
- [ ] Profile the crowd: ensure textured materials and props hold target frame rate around a
|
- [ ] Profile the crowd: ensure textured materials and props hold target frame rate around a
|
||||||
busy entrance.
|
busy entrance.
|
||||||
- [ ] Add LOD or culling for distant NPCs/props if the crowd grows.
|
- [ ] Add LOD or culling for distant NPCs/props if the crowd grows.
|
||||||
|
|
||||||
|
## Generalized Game Asset Tooling
|
||||||
|
|
||||||
|
- [ ] Reuse the OpenRouter image backend for role-aware arbitrary asset generation.
|
||||||
|
- [ ] Add exact-dimension image crop and aspect-preserving resize commands.
|
||||||
|
- [ ] Audit existing assets for role-specific dimensions, aspect, alpha, and tile seams;
|
||||||
|
report projection/perspective checks for visual review.
|
||||||
|
- [ ] Add automated tests and document asset-pipeline usage.
|
||||||
+12
-5
@@ -80,15 +80,22 @@ the NSIS installer requires `makensis` (covered by CI).
|
|||||||
|
|
||||||
The repository lives at `https://git.allucanget.biz/zwitschi/security-game`.
|
The repository lives at `https://git.allucanget.biz/zwitschi/security-game`.
|
||||||
|
|
||||||
Preferred path — use the helper script with a token:
|
Preferred path — use the helper script. It needs either a token or the same
|
||||||
|
credentials used by `git push` (it falls back to `~/.git-credentials`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
GITEA_TOKEN=your_api_token ./tools/publish_release.sh 0.1.0
|
GITEA_TOKEN=your_api_token ./tools/publish_release.sh 0.2.1
|
||||||
|
# or simply: ./tools/publish_release.sh 0.2.1 (uses ~/.git-credentials)
|
||||||
```
|
```
|
||||||
|
|
||||||
The script creates the release from `project.godot`'s `config/version`, uploads
|
The script creates (or reuses) the release from `project.godot`'s
|
||||||
all three `build/` artifacts, and prints the release URL. The token needs only
|
`config/version`, uploads the three portable artifacts **plus** the NSIS
|
||||||
`write:repository` scope on the Gitea host.
|
installer and AppImage when present, and prints the release URL. If CI already
|
||||||
|
published the release, re-running the script only adds missing assets.
|
||||||
|
|
||||||
|
> **Host note**: the release is fully built by CI, but anonymous users can only
|
||||||
|
> browse and download when the instance sets `[service] REQUIRE_SIGNIN_VIEW=false`
|
||||||
|
> in `app.ini` (the repo itself is public). See the TODO for the exact steps.
|
||||||
|
|
||||||
Manual alternative:
|
Manual alternative:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Pillow>=9.2
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate, crop, resize, and quality-check game image assets."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image, ImageChops, ImageOps, ImageStat
|
||||||
|
|
||||||
|
import generate_textures
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"}
|
||||||
|
|
||||||
|
PROFILES = {
|
||||||
|
"texture": {"target": (512, 512), "min": (256, 256), "max": (1024, 1024),
|
||||||
|
"aspect": 1.0, "alpha": None, "view": "flat/orthographic"},
|
||||||
|
"tile": {"target": (512, 512), "min": (256, 256), "max": (1024, 1024),
|
||||||
|
"aspect": 1.0, "alpha": None, "view": "flat/orthographic",
|
||||||
|
"power_of_two": True, "seamless": True},
|
||||||
|
"icon": {"target": (64, 64), "min": (32, 32), "max": (512, 512),
|
||||||
|
"aspect": 1.0, "alpha": True, "view": "flat, centered glyph"},
|
||||||
|
"app_icon": {"target": (1024, 1024), "min": (512, 512), "max": (1024, 1024),
|
||||||
|
"aspect": 1.0, "alpha": None, "view": "square, centered composition"},
|
||||||
|
"overlay": {"target": (512, 512), "min": (256, 256), "max": (1024, 1024),
|
||||||
|
"aspect": 1.0, "alpha": None, "view": "screen-space overlay"},
|
||||||
|
"card": {"target": (256, 128), "min": (128, 64), "max": (1024, 512),
|
||||||
|
"aspect": 2.0, "alpha": None, "view": "flat, front-facing card"},
|
||||||
|
"banner": {"target": (1024, 256), "min": (512, 128), "max": (2048, 512),
|
||||||
|
"aspect": 4.0, "alpha": None, "view": "flat, front-facing banner"},
|
||||||
|
"backdrop": {"target": (1024, 256), "min": (1024, 256), "max": (8192, 2048),
|
||||||
|
"aspect": 4.0, "alpha": None, "view": "straight-on wide backdrop"},
|
||||||
|
"background": {"target": (1920, 1080), "min": (1280, 720), "max": (8192, 4320),
|
||||||
|
"aspect": 16 / 9, "alpha": None, "view": "intended game camera and horizon"},
|
||||||
|
}
|
||||||
|
|
||||||
|
ROLE_GUIDANCE = {
|
||||||
|
"texture": "flat-lit game surface texture, straight-on orthographic view, no perspective",
|
||||||
|
"tile": "seamless tileable surface texture, flat even lighting, orthographic, no perspective or shadows",
|
||||||
|
"icon": "single centered flat UI glyph, clean silhouette, transparent background, no mockup or perspective",
|
||||||
|
"app_icon": "square game application icon, centered composition, no perspective",
|
||||||
|
"overlay": "screen-space overlay texture with transparent edges, no perspective",
|
||||||
|
"card": "single front-facing flat game UI card, no perspective, no surrounding mockup",
|
||||||
|
"banner": "wide front-facing game banner texture, no perspective, no surrounding scene",
|
||||||
|
"backdrop": "wide seamless-looking game environment backdrop, straight-on view, no foreground objects",
|
||||||
|
"background": "wide 16:9 game background environment with a coherent horizon and straight camera view",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_path(value):
|
||||||
|
path = Path(value).expanduser()
|
||||||
|
return path if path.is_absolute() else ROOT / path
|
||||||
|
|
||||||
|
|
||||||
|
def has_alpha(image):
|
||||||
|
return image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info)
|
||||||
|
|
||||||
|
|
||||||
|
def has_transparency(image):
|
||||||
|
if not has_alpha(image):
|
||||||
|
return False
|
||||||
|
return image.convert("RGBA").getchannel("A").getextrema()[0] < 255
|
||||||
|
|
||||||
|
|
||||||
|
def parse_color(value):
|
||||||
|
value = value.strip().lstrip("#")
|
||||||
|
if len(value) not in (6, 8):
|
||||||
|
raise argparse.ArgumentTypeError("color must be #RRGGBB or #RRGGBBAA")
|
||||||
|
try:
|
||||||
|
channels = tuple(int(value[index:index + 2], 16)
|
||||||
|
for index in range(0, len(value), 2))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
"color must use hexadecimal digits") from exc
|
||||||
|
return channels + ((255,) if len(channels) == 3 else ())
|
||||||
|
|
||||||
|
|
||||||
|
def save_image(image, path):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
if suffix in (".jpg", ".jpeg") and image.mode in ("RGBA", "LA"):
|
||||||
|
background = Image.new("RGB", image.size, (0, 0, 0))
|
||||||
|
background.paste(image.convert("RGBA"),
|
||||||
|
mask=image.convert("RGBA").getchannel("A"))
|
||||||
|
image = background
|
||||||
|
image.save(path)
|
||||||
|
print(
|
||||||
|
f"wrote {path.relative_to(ROOT) if path.is_relative_to(ROOT) else path} {image.size}")
|
||||||
|
|
||||||
|
|
||||||
|
def crop_image(source, output, width, height, x=None, y=None):
|
||||||
|
with Image.open(source) as image:
|
||||||
|
image.load()
|
||||||
|
source_width, source_height = image.size
|
||||||
|
if width <= 0 or height <= 0 or width > source_width or height > source_height:
|
||||||
|
raise ValueError(
|
||||||
|
f"crop {width}x{height} must fit inside source {source_width}x{source_height}")
|
||||||
|
if (x is None) != (y is None):
|
||||||
|
raise ValueError("--x and --y must be supplied together")
|
||||||
|
left = (source_width - width) // 2 if x is None else x
|
||||||
|
top = (source_height - height) // 2 if y is None else y
|
||||||
|
if left < 0 or top < 0 or left + width > source_width or top + height > source_height:
|
||||||
|
raise ValueError(
|
||||||
|
"crop origin and dimensions extend beyond the source image")
|
||||||
|
cropped = image.crop((left, top, left + width, top + height))
|
||||||
|
save_image(cropped, output)
|
||||||
|
|
||||||
|
|
||||||
|
def resize_image(source, output, width, height, fit="contain", background=(0, 0, 0, 0)):
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
raise ValueError("resize dimensions must be positive")
|
||||||
|
with Image.open(source) as image:
|
||||||
|
image.load()
|
||||||
|
if fit == "stretch":
|
||||||
|
result = image.resize((width, height), Image.Resampling.LANCZOS)
|
||||||
|
elif fit == "cover":
|
||||||
|
result = ImageOps.fit(image, (width, height),
|
||||||
|
method=Image.Resampling.LANCZOS)
|
||||||
|
else:
|
||||||
|
contained = ImageOps.contain(
|
||||||
|
image, (width, height), method=Image.Resampling.LANCZOS)
|
||||||
|
use_alpha = has_alpha(image) or background[3] < 255
|
||||||
|
mode = "RGBA" if use_alpha else "RGB"
|
||||||
|
fill = background if use_alpha else background[:3]
|
||||||
|
result = Image.new(mode, (width, height), fill)
|
||||||
|
contained = contained.convert(mode)
|
||||||
|
offset = ((width - contained.width) // 2,
|
||||||
|
(height - contained.height) // 2)
|
||||||
|
if use_alpha:
|
||||||
|
result.alpha_composite(contained, offset)
|
||||||
|
else:
|
||||||
|
result.paste(contained, offset)
|
||||||
|
save_image(result, output)
|
||||||
|
|
||||||
|
|
||||||
|
def infer_role(path):
|
||||||
|
parts = [part.lower() for part in path.parts]
|
||||||
|
stem = path.stem.lower()
|
||||||
|
if "icons" in parts:
|
||||||
|
return "icon"
|
||||||
|
if "cards" in parts:
|
||||||
|
return "card"
|
||||||
|
if stem == "icon":
|
||||||
|
return "app_icon"
|
||||||
|
if "vignette" in stem or "overlay" in stem:
|
||||||
|
return "overlay"
|
||||||
|
if "skyline" in stem or "backdrop" in parts:
|
||||||
|
return "backdrop"
|
||||||
|
if "awning" in stem or "banner" in parts:
|
||||||
|
return "banner"
|
||||||
|
if stem in {"brick_facade", "concrete", "asphalt"} or "tiles" in parts:
|
||||||
|
return "tile"
|
||||||
|
if "backgrounds" in parts or "background" in parts:
|
||||||
|
return "background"
|
||||||
|
return "texture"
|
||||||
|
|
||||||
|
|
||||||
|
def _mean_edge_difference(image):
|
||||||
|
rgb = image.convert("RGB")
|
||||||
|
width, height = rgb.size
|
||||||
|
horizontal = ImageStat.Stat(ImageChops.difference(
|
||||||
|
rgb.crop((0, 0, 1, height)), rgb.crop((width - 1, 0, width, height))))
|
||||||
|
vertical = ImageStat.Stat(ImageChops.difference(
|
||||||
|
rgb.crop((0, 0, width, 1)), rgb.crop((0, height - 1, width, height))))
|
||||||
|
return max(sum(horizontal.mean) / len(horizontal.mean),
|
||||||
|
sum(vertical.mean) / len(vertical.mean))
|
||||||
|
|
||||||
|
|
||||||
|
def audit_image(path, role="auto", seam_threshold=18.0):
|
||||||
|
path = Path(path)
|
||||||
|
chosen_role = infer_role(path) if role == "auto" else role
|
||||||
|
profile = PROFILES[chosen_role]
|
||||||
|
errors = []
|
||||||
|
warnings = []
|
||||||
|
try:
|
||||||
|
with Image.open(path) as image:
|
||||||
|
image.load()
|
||||||
|
width, height = image.size
|
||||||
|
alpha = has_transparency(image)
|
||||||
|
minimum = profile["min"]
|
||||||
|
maximum = profile["max"]
|
||||||
|
if width < minimum[0] or height < minimum[1]:
|
||||||
|
errors.append(
|
||||||
|
f"too small: {width}x{height}; minimum is {minimum[0]}x{minimum[1]}")
|
||||||
|
if width > maximum[0] or height > maximum[1]:
|
||||||
|
errors.append(
|
||||||
|
f"too large: {width}x{height}; maximum is {maximum[0]}x{maximum[1]}")
|
||||||
|
aspect = profile.get("aspect")
|
||||||
|
actual_aspect = width / height
|
||||||
|
if aspect and abs(actual_aspect - aspect) / aspect > 0.12:
|
||||||
|
errors.append(
|
||||||
|
f"wrong aspect ratio: {actual_aspect:.3f}; expected about {aspect:.3f}")
|
||||||
|
if profile.get("alpha") is True and not alpha:
|
||||||
|
errors.append("transparency required but image is opaque")
|
||||||
|
elif profile.get("alpha") is False and alpha:
|
||||||
|
warnings.append(
|
||||||
|
"image has an alpha channel although this role is normally opaque")
|
||||||
|
if profile.get("power_of_two") and (width & (width - 1) or height & (height - 1)):
|
||||||
|
warnings.append("dimensions are not powers of two")
|
||||||
|
edge_difference = None
|
||||||
|
if profile.get("seamless"):
|
||||||
|
edge_difference = _mean_edge_difference(image)
|
||||||
|
if edge_difference > seam_threshold:
|
||||||
|
errors.append(
|
||||||
|
f"tile edges differ by {edge_difference:.1f} (limit {seam_threshold:.1f})")
|
||||||
|
manual_review = [
|
||||||
|
f"Confirm {profile.get('view', 'intended')} perspective/projection visually; "
|
||||||
|
"automated checks cannot infer it reliably."
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"path": str(path), "role": chosen_role, "size": [width, height],
|
||||||
|
"alpha": alpha, "edge_difference": edge_difference,
|
||||||
|
"quality": "bad" if errors else "good",
|
||||||
|
"errors": errors, "warnings": warnings, "manual_review": manual_review,
|
||||||
|
}
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
return {
|
||||||
|
"path": str(path), "role": chosen_role, "size": None, "alpha": None,
|
||||||
|
"edge_difference": None, "quality": "bad", "errors": [f"cannot read image: {exc}"],
|
||||||
|
"warnings": [], "manual_review": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def image_paths(values):
|
||||||
|
found = []
|
||||||
|
for value in values:
|
||||||
|
path = resolve_path(value)
|
||||||
|
if path.is_dir():
|
||||||
|
found.extend(item for item in path.rglob(
|
||||||
|
"*") if item.is_file() and item.suffix.lower() in IMAGE_EXTENSIONS)
|
||||||
|
elif path.suffix.lower() in IMAGE_EXTENSIONS:
|
||||||
|
found.append(path)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"not an image file or directory: {path}")
|
||||||
|
return sorted(set(found))
|
||||||
|
|
||||||
|
|
||||||
|
def _generate(args):
|
||||||
|
profile = PROFILES[args.role]
|
||||||
|
if (args.width is None) != (args.height is None):
|
||||||
|
raise ValueError("--width and --height must be supplied together")
|
||||||
|
width, height = (
|
||||||
|
args.width, args.height) if args.width else profile["target"]
|
||||||
|
transparent = args.transparent if args.transparent is not None else profile.get(
|
||||||
|
"alpha") is True
|
||||||
|
prompt = f"{args.prompt.strip()}. {ROLE_GUIDANCE[args.role]}."
|
||||||
|
image = generate_textures.generate_image(
|
||||||
|
prompt, args.model, args.request_size, transparent)
|
||||||
|
if args.role == "icon" and has_alpha(image):
|
||||||
|
image = resize_image_from_image(
|
||||||
|
image, width, height, "contain", (0, 0, 0, 0))
|
||||||
|
else:
|
||||||
|
image = ImageOps.fit(image, (width, height),
|
||||||
|
method=Image.Resampling.LANCZOS)
|
||||||
|
save_image(image, resolve_path(args.output))
|
||||||
|
|
||||||
|
|
||||||
|
def resize_image_from_image(image, width, height, fit, background):
|
||||||
|
if fit == "cover":
|
||||||
|
return ImageOps.fit(image, (width, height), method=Image.Resampling.LANCZOS)
|
||||||
|
contained = ImageOps.contain(
|
||||||
|
image, (width, height), method=Image.Resampling.LANCZOS)
|
||||||
|
mode = "RGBA" if has_alpha(image) or background[3] < 255 else "RGB"
|
||||||
|
result = Image.new(mode, (width, height),
|
||||||
|
background if mode == "RGBA" else background[:3])
|
||||||
|
contained = contained.convert(mode)
|
||||||
|
offset = ((width - contained.width) // 2, (height - contained.height) // 2)
|
||||||
|
if mode == "RGBA":
|
||||||
|
result.alpha_composite(contained, offset)
|
||||||
|
else:
|
||||||
|
result.paste(contained, offset)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Create and validate game image assets.")
|
||||||
|
commands = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
generate = commands.add_parser(
|
||||||
|
"generate", help="generate an image using the existing OpenRouter backend")
|
||||||
|
generate.add_argument("--prompt", required=True)
|
||||||
|
generate.add_argument("--output", required=True,
|
||||||
|
help="output path, relative to the project root or absolute")
|
||||||
|
generate.add_argument("--role", choices=PROFILES, required=True)
|
||||||
|
generate.add_argument(
|
||||||
|
"--model", default=generate_textures.DEFAULT_IMAGE_MODEL)
|
||||||
|
generate.add_argument("--request-size", type=int,
|
||||||
|
default=generate_textures.GEN_SIZE)
|
||||||
|
generate.add_argument("--width", type=int)
|
||||||
|
generate.add_argument("--height", type=int)
|
||||||
|
alpha = generate.add_mutually_exclusive_group()
|
||||||
|
alpha.add_argument("--transparent", dest="transparent",
|
||||||
|
action="store_true")
|
||||||
|
alpha.add_argument("--opaque", dest="transparent", action="store_false")
|
||||||
|
generate.set_defaults(transparent=None)
|
||||||
|
generate.set_defaults(handler=_generate)
|
||||||
|
|
||||||
|
crop = commands.add_parser(
|
||||||
|
"crop", help="crop an exact-size region; defaults to the image center")
|
||||||
|
crop.add_argument("input")
|
||||||
|
crop.add_argument("output")
|
||||||
|
crop.add_argument("--width", type=int, required=True)
|
||||||
|
crop.add_argument("--height", type=int, required=True)
|
||||||
|
crop.add_argument("--x", type=int)
|
||||||
|
crop.add_argument("--y", type=int)
|
||||||
|
crop.set_defaults(handler=lambda args: crop_image(resolve_path(args.input), resolve_path(args.output),
|
||||||
|
args.width, args.height, args.x, args.y))
|
||||||
|
|
||||||
|
resize = commands.add_parser(
|
||||||
|
"resize", help="resize to exact dimensions while preserving aspect by default")
|
||||||
|
resize.add_argument("input")
|
||||||
|
resize.add_argument("output")
|
||||||
|
resize.add_argument("--width", type=int, required=True)
|
||||||
|
resize.add_argument("--height", type=int, required=True)
|
||||||
|
resize.add_argument("--fit", choices=("contain",
|
||||||
|
"cover", "stretch"), default="contain")
|
||||||
|
resize.add_argument("--background", type=parse_color, default=(0, 0, 0, 0))
|
||||||
|
resize.set_defaults(handler=lambda args: resize_image(resolve_path(args.input), resolve_path(args.output),
|
||||||
|
args.width, args.height, args.fit, args.background))
|
||||||
|
|
||||||
|
audit = commands.add_parser(
|
||||||
|
"audit", help="classify images as good/bad using their game-asset role")
|
||||||
|
audit.add_argument("paths", nargs="+", help="image files or directories")
|
||||||
|
audit.add_argument("--role", choices=("auto", *PROFILES), default="auto")
|
||||||
|
audit.add_argument("--seam-threshold", type=float, default=18.0)
|
||||||
|
audit.add_argument("--json", action="store_true",
|
||||||
|
help="emit machine-readable JSON")
|
||||||
|
audit.set_defaults(handler=_audit)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def _audit(args):
|
||||||
|
results = [audit_image(path, args.role, args.seam_threshold)
|
||||||
|
for path in image_paths(args.paths)]
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(results, indent=2))
|
||||||
|
else:
|
||||||
|
good_count = sum(result["quality"] == "good" for result in results)
|
||||||
|
for result in results:
|
||||||
|
size = "unreadable" if result["size"] is None else "x".join(
|
||||||
|
map(str, result["size"]))
|
||||||
|
print(
|
||||||
|
f"{result['quality'].upper():4} {result['role']:10} {size:12} {result['path']}")
|
||||||
|
for message in result["errors"] + result["warnings"]:
|
||||||
|
print(f" - {message}")
|
||||||
|
for message in result["manual_review"]:
|
||||||
|
print(f" - review: {message}")
|
||||||
|
print(f"Summary: {good_count} good, {len(results) - good_count} bad")
|
||||||
|
return 1 if any(result["quality"] == "bad" for result in results) else 0
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
try:
|
||||||
|
result = args.handler(args)
|
||||||
|
return result if isinstance(result, int) else 0
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -316,8 +316,10 @@ def app_icon():
|
|||||||
src_png = os.path.join(ASSETS, "icon.png")
|
src_png = os.path.join(ASSETS, "icon.png")
|
||||||
ran = False
|
ran = False
|
||||||
for cmd in (
|
for cmd in (
|
||||||
["magick", src_png, "-background", "none", "-define", "icon:auto-resize=48,32,16", ico_path],
|
["magick", src_png, "-background", "none", "-define",
|
||||||
["convert", src_png, "-background", "none", "-define", "icon:auto-resize=48,32,16", ico_path],
|
"icon:auto-resize=48,32,16", ico_path],
|
||||||
|
["convert", src_png, "-background", "none",
|
||||||
|
"-define", "icon:auto-resize=48,32,16", ico_path],
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
subprocess.run(cmd, check=True, capture_output=True)
|
subprocess.run(cmd, check=True, capture_output=True)
|
||||||
@@ -329,7 +331,8 @@ def app_icon():
|
|||||||
print("wrote assets/textures/icon.ico (BMP 16/32/48 via ImageMagick)")
|
print("wrote assets/textures/icon.ico (BMP 16/32/48 via ImageMagick)")
|
||||||
else:
|
else:
|
||||||
print("WARNING: ImageMagick not found; writing PIL ICO (may be PNG-compressed)")
|
print("WARNING: ImageMagick not found; writing PIL ICO (may be PNG-compressed)")
|
||||||
Image.open(src_png).convert("RGBA").save(ico_path, format="ICO", sizes=[(48, 48), (32, 32), (16, 16)])
|
Image.open(src_png).convert("RGBA").save(
|
||||||
|
ico_path, format="ICO", sizes=[(48, 48), (32, 32), (16, 16)])
|
||||||
|
|
||||||
|
|
||||||
def cards():
|
def cards():
|
||||||
@@ -369,7 +372,7 @@ def _api_key() -> str:
|
|||||||
return key
|
return key
|
||||||
|
|
||||||
|
|
||||||
def _generate_image(prompt: str, model: str, size: int,
|
def generate_image(prompt: str, model: str, size: int,
|
||||||
transparent: bool = False) -> Image.Image:
|
transparent: bool = False) -> Image.Image:
|
||||||
"""Call the OpenRouter image API and return the decoded PIL image.
|
"""Call the OpenRouter image API and return the decoded PIL image.
|
||||||
|
|
||||||
@@ -435,7 +438,7 @@ def _fit(img: Image.Image, max_dim: int = MAX_DIM) -> Image.Image:
|
|||||||
def generate_ai_textures(prompt: str, name: str, model: str, size: int,
|
def generate_ai_textures(prompt: str, name: str, model: str, size: int,
|
||||||
transparent: bool = False) -> None:
|
transparent: bool = False) -> None:
|
||||||
"""Generate one texture via OpenRouter and save it under assets/textures/."""
|
"""Generate one texture via OpenRouter and save it under assets/textures/."""
|
||||||
img = _generate_image(prompt, model, size, transparent)
|
img = generate_image(prompt, model, size, transparent)
|
||||||
img = _fit(img)
|
img = _fit(img)
|
||||||
if transparent:
|
if transparent:
|
||||||
img = img.convert("RGBA")
|
img = img.convert("RGBA")
|
||||||
|
|||||||
+48
-23
@@ -2,8 +2,9 @@
|
|||||||
# Publish a release to the Gitea/Forgejo host, uploading the built artifacts.
|
# Publish a release to the Gitea/Forgejo host, uploading the built artifacts.
|
||||||
#
|
#
|
||||||
# Requires:
|
# Requires:
|
||||||
# - GITEA_TOKEN (a token with write access to the repo)
|
# - GITEA_TOKEN (token with write access) OR credentials usable for basic auth
|
||||||
# - build artifacts from tools/build_releases.sh (or already exported)
|
# (e.g. the same ones stored in ~/.git-credentials by `git push`)
|
||||||
|
# - build artifacts from tools/build_releases.sh (+ optional installer/AppImage)
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# GITEA_TOKEN=xxx ./tools/publish_release.sh [version]
|
# GITEA_TOKEN=xxx ./tools/publish_release.sh [version]
|
||||||
@@ -12,45 +13,69 @@ set -euo pipefail
|
|||||||
|
|
||||||
REPO="${REPO:-zwitschi/security-game}"
|
REPO="${REPO:-zwitschi/security-game}"
|
||||||
HOST="${HOST:-https://git.allucanget.biz}"
|
HOST="${HOST:-https://git.allucanget.biz}"
|
||||||
API="$HOST/api/v1/repos/$REPO/releases"
|
|
||||||
VERSION="${1:-$(grep -oP 'config/version="\K[^"]+' project.godot)}"
|
VERSION="${1:-$(grep -oP 'config/version="\K[^"]+' project.godot)}"
|
||||||
TAG="v$VERSION"
|
TAG="v$VERSION"
|
||||||
|
|
||||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
||||||
echo "error: GITEA_TOKEN is not set (see docs/RELEASING.md)" >&2
|
AUTH=("Authorization: token $GITEA_TOKEN")
|
||||||
|
elif [[ -f "$HOME/.git-credentials" ]]; then
|
||||||
|
CRED="$(python3 -c "
|
||||||
|
import os
|
||||||
|
for l in open(os.path.expanduser('~/.git-credentials')):
|
||||||
|
if '$HOST'.split('//')[1] in l:
|
||||||
|
print(l.split('://',1)[1].split('@')[0]); break")"
|
||||||
|
if [[ -z "$CRED" ]]; then
|
||||||
|
echo "error: no credentials for $HOST in ~/.git-credentials" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "error: set GITEA_TOKEN or provide ~/.git-credentials" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Artifacts must already exist (run tools/build_releases.sh first).
|
# Artifacts that must exist (run tools/build_releases.sh first).
|
||||||
printf -v BS 'build/step-line-linux-%s.x86_64' "$VERSION"
|
portables=(
|
||||||
printf -v BS_WIN 'build/step-line-windows-%s.exe' "$VERSION"
|
"build/step-line-linux-$VERSION.x86_64"
|
||||||
printf -v BS_MAC 'build/step-line-macos-%s.zip' "$VERSION"
|
"build/step-line-windows-$VERSION.exe"
|
||||||
for f in "$BS" "$BS_WIN" "$BS_MAC"; do
|
"build/step-line-macos-$VERSION.zip"
|
||||||
if [[ ! -f "$f" ]]; then
|
)
|
||||||
echo "error: missing artifact $f — run ./tools/build_releases.sh first" >&2
|
extras=(
|
||||||
exit 1
|
"build/step-line-setup-$VERSION.exe"
|
||||||
|
"build/step-line-$VERSION-x86_64.AppImage"
|
||||||
|
)
|
||||||
|
all_artifacts=("${portables[@]}")
|
||||||
|
for f in "${extras[@]}"; do
|
||||||
|
if [[ -f "$f" ]]; then
|
||||||
|
all_artifacts+=("$f")
|
||||||
|
else
|
||||||
|
echo "note: optional artifact missing (skipping): $f"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
API="$HOST/api/v1/repos/$REPO/releases"
|
||||||
echo "==> Creating release $TAG on $HOST/$REPO"
|
echo "==> Creating release $TAG on $HOST/$REPO"
|
||||||
RELEASE_JSON="$(curl -s -X POST "$API" \
|
RELEASE_ID="$(curl -s -X POST "$API" "${AUTH[@]}" \
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Step Line $VERSION — playable prototype. See README for controls.\",\"draft\":false,\"prerelease\":false}")"
|
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Step Line $VERSION — playable prototype. See README for controls.\",\"draft\":false,\"prerelease\":false}" \
|
||||||
|
| python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)"
|
||||||
|
|
||||||
RELEASE_ID="$(echo "$RELEASE_JSON" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)"
|
# If the release already exists (tag already pushed), reuse it.
|
||||||
if [[ -z "$RELEASE_ID" ]]; then
|
if [[ -z "$RELEASE_ID" ]]; then
|
||||||
echo "error: could not create release; response:" >&2
|
RELEASE_ID="$(curl -s "${AUTH[@]}" "$API/tags/$TAG" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)"
|
||||||
echo "$RELEASE_JSON" >&2
|
fi
|
||||||
|
if [[ -z "$RELEASE_ID" ]]; then
|
||||||
|
echo "error: could not create or find release $TAG" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Release id: $RELEASE_ID"
|
echo "Release id: $RELEASE_ID"
|
||||||
|
|
||||||
for f in "$BS" "$BS_WIN" "$BS_MAC"; do
|
for f in "${all_artifacts[@]}"; do
|
||||||
|
if [[ ! -f "$f" ]]; then
|
||||||
|
echo "error: missing artifact $f — run ./tools/build_releases.sh first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
echo "==> Uploading $(basename "$f")"
|
echo "==> Uploading $(basename "$f")"
|
||||||
curl -s -X POST "$API/$RELEASE_ID/assets" \
|
curl -s -X POST "$API/$RELEASE_ID/assets" "${AUTH[@]}" -F "attachment=@$f" >/dev/null
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
|
||||||
-F "attachment=@$f" >/dev/null
|
|
||||||
done
|
done
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
import asset_pipeline
|
||||||
|
|
||||||
|
|
||||||
|
class AssetPipelineTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp_dir.name)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_crop_uses_center_and_exact_dimensions(self):
|
||||||
|
source = self.root / "source.png"
|
||||||
|
output = self.root / "crop.png"
|
||||||
|
Image.new("RGB", (80, 60), (20, 40, 60)).save(source)
|
||||||
|
|
||||||
|
asset_pipeline.crop_image(source, output, 32, 24)
|
||||||
|
|
||||||
|
with Image.open(output) as image:
|
||||||
|
self.assertEqual(image.size, (32, 24))
|
||||||
|
|
||||||
|
def test_resize_contain_preserves_aspect_and_exact_canvas(self):
|
||||||
|
source = self.root / "source.png"
|
||||||
|
output = self.root / "resized.png"
|
||||||
|
Image.new("RGB", (80, 40), (20, 40, 60)).save(source)
|
||||||
|
|
||||||
|
asset_pipeline.resize_image(source, output, 64, 64, "contain")
|
||||||
|
|
||||||
|
with Image.open(output) as image:
|
||||||
|
self.assertEqual(image.size, (64, 64))
|
||||||
|
self.assertEqual(image.getpixel((32, 0)), (0, 0, 0, 0))
|
||||||
|
|
||||||
|
def test_texture_audit_accepts_valid_tile_and_rejects_bad_seams(self):
|
||||||
|
good = self.root / "good.png"
|
||||||
|
bad = self.root / "bad.png"
|
||||||
|
Image.new("RGB", (512, 512), (80, 80, 80)).save(good)
|
||||||
|
mismatch = Image.new("RGB", (512, 512), (80, 80, 80))
|
||||||
|
for y in range(512):
|
||||||
|
mismatch.putpixel((0, y), (255, 0, 0))
|
||||||
|
mismatch.save(bad)
|
||||||
|
|
||||||
|
self.assertEqual(asset_pipeline.audit_image(
|
||||||
|
good, "tile")["quality"], "good")
|
||||||
|
self.assertEqual(asset_pipeline.audit_image(
|
||||||
|
bad, "tile")["quality"], "bad")
|
||||||
|
|
||||||
|
def test_background_audit_rejects_small_image(self):
|
||||||
|
image_path = self.root / "small.png"
|
||||||
|
Image.new("RGB", (640, 360)).save(image_path)
|
||||||
|
|
||||||
|
result = asset_pipeline.audit_image(image_path, "background")
|
||||||
|
|
||||||
|
self.assertEqual(result["quality"], "bad")
|
||||||
|
self.assertTrue(
|
||||||
|
any("too small" in error for error in result["errors"]))
|
||||||
|
|
||||||
|
def test_grayscale_vignette_is_valid_without_alpha(self):
|
||||||
|
image_path = self.root / "vignette.png"
|
||||||
|
Image.new("L", (512, 512), 0).save(image_path)
|
||||||
|
|
||||||
|
self.assertEqual(asset_pipeline.audit_image(
|
||||||
|
image_path, "overlay")["quality"], "good")
|
||||||
|
|
||||||
|
def test_icon_audit_rejects_opaque_rgba_images(self):
|
||||||
|
image_path = self.root / "opaque_icon.png"
|
||||||
|
Image.new("RGBA", (64, 64), (255, 255, 255, 255)).save(image_path)
|
||||||
|
|
||||||
|
result = asset_pipeline.audit_image(image_path, "icon")
|
||||||
|
|
||||||
|
self.assertEqual(result["quality"], "bad")
|
||||||
|
self.assertTrue(any("transparency required" in error
|
||||||
|
for error in result["errors"]))
|
||||||
|
|
||||||
|
def test_generation_reuses_existing_backend_and_targets_role_dimensions(self):
|
||||||
|
output = self.root / "background.png"
|
||||||
|
args = type("Args", (), {
|
||||||
|
"prompt": "Hollywood at night", "role": "background", "output": str(output),
|
||||||
|
"width": 1920, "height": 1080, "transparent": None,
|
||||||
|
"model": "test-model", "request_size": 2048,
|
||||||
|
})()
|
||||||
|
generated = Image.new("RGB", (256, 256), (20, 40, 60))
|
||||||
|
|
||||||
|
with patch.object(asset_pipeline.generate_textures, "generate_image",
|
||||||
|
return_value=generated) as backend:
|
||||||
|
asset_pipeline._generate(args)
|
||||||
|
|
||||||
|
self.assertIn("Hollywood at night", backend.call_args.args[0])
|
||||||
|
self.assertIn("wide 16:9", backend.call_args.args[0])
|
||||||
|
with Image.open(output) as image:
|
||||||
|
self.assertEqual(image.size, (1920, 1080))
|
||||||
|
|
||||||
|
def test_role_inference_uses_game_asset_paths(self):
|
||||||
|
self.assertEqual(asset_pipeline.infer_role(
|
||||||
|
Path("assets/textures/icons/wand.png")), "icon")
|
||||||
|
self.assertEqual(asset_pipeline.infer_role(
|
||||||
|
Path("assets/textures/cards/ticket.png")), "card")
|
||||||
|
self.assertEqual(asset_pipeline.infer_role(
|
||||||
|
Path("assets/textures/skyline.png")), "backdrop")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user