Build SpriteForge asset studio and pull GPU relay
This commit is contained in:
commit
ebf40a7451
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
.vscode/
|
||||
dist/
|
||||
build/
|
||||
.sfcache/
|
||||
relay-data/
|
||||
deploy/relay/.env
|
||||
*.egg-info/
|
||||
*.obj
|
||||
|
||||
.cmake-*
|
||||
.test-tmp*/
|
||||
.stage3-smoke/
|
||||
.stage4-smoke/
|
||||
.stage5-smoke/
|
||||
.stage5-cache/
|
||||
.stage6-*
|
||||
.stage8-*
|
||||
|
||||
# Human review/reference rasters are local inputs and may contain third-party art.
|
||||
examples/*.png
|
||||
8
CLAUDE.md
Normal file
8
CLAUDE.md
Normal file
@ -0,0 +1,8 @@
|
||||
# SpriteForge agent discipline
|
||||
|
||||
- Never open files in `build/`, `assets/**/*.png`, or `.sfcache/`.
|
||||
- Never list directories containing thousands of files; use `sf list`.
|
||||
- Check generated results only with `sf validate` and `sf ascii`.
|
||||
- When debugging a backend, first run one asset with `--dry-run`.
|
||||
- SpriteForge is standalone. Never import game code or hardcode game entity names.
|
||||
- YAML manifests in consuming projects are the only project-specific source of truth.
|
||||
31
CMakeLists.txt
Normal file
31
CMakeLists.txt
Normal file
@ -0,0 +1,31 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(spriteforge_runtime LANGUAGES C CXX)
|
||||
|
||||
option(SPRITEFORGE_BUILD_TESTS "Build C99/C++17 runtime tests" ON)
|
||||
option(SPRITEFORGE_BUILD_RAYLIB_EXAMPLE "Build isolated raylib viewer" OFF)
|
||||
|
||||
add_library(spriteforge_headers INTERFACE)
|
||||
target_include_directories(spriteforge_headers INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/include")
|
||||
|
||||
if(SPRITEFORGE_BUILD_TESTS)
|
||||
enable_testing()
|
||||
add_executable(sfa_c99_smoke tests/c99_smoke.c)
|
||||
target_link_libraries(sfa_c99_smoke PRIVATE spriteforge_headers)
|
||||
if(NOT MSVC)
|
||||
target_link_libraries(sfa_c99_smoke PRIVATE m)
|
||||
endif()
|
||||
set_property(TARGET sfa_c99_smoke PROPERTY C_STANDARD 99)
|
||||
add_executable(sfp_c99_smoke tests/sfp_c99_smoke.c)
|
||||
target_link_libraries(sfp_c99_smoke PRIVATE spriteforge_headers)
|
||||
set_property(TARGET sfp_c99_smoke PROPERTY C_STANDARD 99)
|
||||
add_executable(sfa_cpp17_smoke tests/cpp17_smoke.cpp)
|
||||
target_link_libraries(sfa_cpp17_smoke PRIVATE spriteforge_headers)
|
||||
set_property(TARGET sfa_cpp17_smoke PROPERTY CXX_STANDARD 17)
|
||||
endif()
|
||||
|
||||
if(SPRITEFORGE_BUILD_RAYLIB_EXAMPLE)
|
||||
find_package(raylib 6 REQUIRED)
|
||||
add_executable(spriteforge_raylib_example examples/raylib_cpp17/main.cpp)
|
||||
target_link_libraries(spriteforge_raylib_example PRIVATE spriteforge_headers raylib)
|
||||
set_property(TARGET spriteforge_raylib_example PROPERTY CXX_STANDARD 17)
|
||||
endif()
|
||||
39
README.md
Normal file
39
README.md
Normal file
@ -0,0 +1,39 @@
|
||||
# SpriteForge
|
||||
|
||||
Standalone reusable 2D sprite asset pipeline. Games own YAML manifests and
|
||||
source assets; SpriteForge owns generation, post-processing and the `.sfa` /
|
||||
`.sfp` runtime formats. It never imports game code.
|
||||
|
||||
Stages 1-8 contain the binary format, Python writer/reader, dependency-free
|
||||
C99/C++17 loader and software blitters, an isolated raylib example, and the
|
||||
validated YAML manifest/CLI foundation, the deterministic procedural backend,
|
||||
text-first generated-library introspection, parallel incremental builds, and a
|
||||
headless Blender animation backend with Z-derived layer ordering, local PNG
|
||||
import/compositing, code generation and watch-mode hot reload signaling.
|
||||
|
||||
```powershell
|
||||
python -m venv .venv
|
||||
.venv\Scripts\pip install -e ".[test]"
|
||||
.venv\Scripts\pytest
|
||||
```
|
||||
|
||||
Include `include/sfa.h` from C99 or C++17. C++17 users can include
|
||||
`include/sfa.hpp` for an RAII wrapper. See `docs/format-v1.md`.
|
||||
|
||||
Contact sheets are for humans only. Agents must never open generated PNGs;
|
||||
use textual `sf validate` and `sf ascii` instead.
|
||||
|
||||
Manifest usage is documented in `docs/manifest-v1.md`.
|
||||
Procedural generators are documented in `docs/procedural.md`.
|
||||
Local PNG import and composition are documented in `docs/import-composite.md`.
|
||||
Agent-safe introspection is documented in `docs/introspection.md`.
|
||||
Incremental builds are documented in `docs/incremental-builds.md`.
|
||||
The Blender protocol is documented in `docs/blender-backend.md`.
|
||||
Code generation and hot reload are documented in `docs/codegen-watch.md`.
|
||||
|
||||
The human-facing asset authoring application is documented in
|
||||
`docs/studio.md`. Remote rendering setup is in `docs/external-gpu.md`, and the
|
||||
runtime handoff is in `docs/game-integration.md`.
|
||||
|
||||
For a GPU computer behind NAT without VPN or incoming ports, use the asynchronous
|
||||
pull architecture in `docs/pull-relay.md`.
|
||||
7
deploy/relay/Caddyfile
Normal file
7
deploy/relay/Caddyfile
Normal file
@ -0,0 +1,7 @@
|
||||
{$RELAY_DOMAIN} {
|
||||
encode zstd gzip
|
||||
reverse_proxy relay:8787
|
||||
request_body {
|
||||
max_size 64MB
|
||||
}
|
||||
}
|
||||
8
deploy/relay/Dockerfile
Normal file
8
deploy/relay/Dockerfile
Normal file
@ -0,0 +1,8 @@
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY src ./src
|
||||
RUN pip install --no-cache-dir .
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 8787
|
||||
CMD ["sf", "relay", "serve", "--data", "/data", "--host", "0.0.0.0", "--port", "8787"]
|
||||
29
deploy/relay/compose.yaml
Normal file
29
deploy/relay/compose.yaml
Normal file
@ -0,0 +1,29 @@
|
||||
services:
|
||||
relay:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: deploy/relay/Dockerfile
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SF_RELAY_CLIENT_TOKEN: ${SF_RELAY_CLIENT_TOKEN:?set client token}
|
||||
SF_RELAY_WORKER_TOKEN: ${SF_RELAY_WORKER_TOKEN:?set worker token}
|
||||
volumes:
|
||||
- relay-data:/data
|
||||
expose:
|
||||
- "8787"
|
||||
caddy:
|
||||
image: caddy:2
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
RELAY_DOMAIN: ${RELAY_DOMAIN:?set relay domain}
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
depends_on:
|
||||
- relay
|
||||
volumes:
|
||||
relay-data:
|
||||
caddy-data:
|
||||
77
docs/blender-backend.md
Normal file
77
docs/blender-backend.md
Normal file
@ -0,0 +1,77 @@
|
||||
# Blender backend
|
||||
|
||||
The Blender backend launches an external Blender process without a UI:
|
||||
|
||||
```text
|
||||
blender -b rig.blend -P blender_driver.py -- --request request.json ...
|
||||
```
|
||||
|
||||
No rendered image enters an agent context. The driver writes JSON metadata and
|
||||
NumPy RGBA, view-normal and Z arrays into a temporary directory; the parent
|
||||
process consumes them and deletes the directory.
|
||||
|
||||
Blender 5+ pass extraction uses a temporary compositor multi-layer EXR because
|
||||
that release removed the legacy `Image.layers` API. Official Blender builds
|
||||
bundle OpenImageIO, so this compatibility path adds no Python dependency to the
|
||||
host project. Blender 4 and older retain the direct Render Result path.
|
||||
|
||||
```yaml
|
||||
demo_actor:
|
||||
backend: blender
|
||||
rig: rigs/demo_actor.blend
|
||||
parts: {body: BodyMesh, head: HeadMesh, weapon: WeaponMesh}
|
||||
anims: [idle, walk, attack]
|
||||
dirs: 8
|
||||
fps: 12
|
||||
params:
|
||||
render_size: 192x192
|
||||
ortho_scale: 4.0
|
||||
elevation: 35.264
|
||||
samples: 16
|
||||
# Manifest clip names can map to arbitrary actions in a reusable source rig.
|
||||
actions: {idle: Idle_Loop, walk: Walk_Loop, attack: Pistol_Shoot}
|
||||
# Sample the full source actions into compact pre-rendered sprite cycles.
|
||||
frames: {idle: 4, walk: 8, attack: 6}
|
||||
animation_fps: {idle: 8, walk: 12, attack: 12}
|
||||
loop_sampling: {idle: true, walk: true, attack: false}
|
||||
direction_offset_deg: 90
|
||||
camera_target: [0, 0, 0.9]
|
||||
model_rotation_deg: 0
|
||||
# One logical Z-sorted layer may contain several Blender objects.
|
||||
part_members: {body: [BodyMesh, HelmetMesh]}
|
||||
# Or select every renderable object whose name starts with this prefix.
|
||||
part_prefixes: {body: actor_body_}
|
||||
```
|
||||
|
||||
`parts` values are directly renderable Blender object names. Each object is
|
||||
rendered alone while armatures remain active. The camera is orthographic;
|
||||
direction zero looks from +X toward the origin and subsequent directions rotate
|
||||
counter-clockwise around world Z.
|
||||
|
||||
For every animation/direction/frame, pairwise Z comparisons create a
|
||||
back-to-front layer permutation. If both relative orders occur over a material
|
||||
fraction of overlapping pixels, `sf validate` reports the exact asset,
|
||||
animation, direction, frame and layer pair and advises splitting the component
|
||||
into passes. Cycles fall back to median depth and also produce a warning.
|
||||
|
||||
Blender Z is quantized per frame into 8 bits while `depth_min/depth_max` preserve
|
||||
the camera-space range in world units. Normal-pass X/Y values are stored
|
||||
directly; positive Z is reconstructed by the runtime.
|
||||
|
||||
The executable is resolved from `params.executable`, then `BLENDER_BIN`, then
|
||||
`PATH`. On Windows SpriteForge also discovers official versioned installs under
|
||||
`Program Files/Blender Foundation`, because the Blender installer commonly does
|
||||
not add itself to `PATH`. Other parameters
|
||||
are `render_size`, `samples`, `ortho_scale`, `elevation`, `camera_distance`,
|
||||
`engine`, `timeout_seconds`, `depth_epsilon`, `crossing_ratio`, `dither`,
|
||||
`actions`, `frames`, `loop_sampling`, `direction_offset_deg`, `camera_target`,
|
||||
`model_rotation_deg`, `part_members`, and `part_prefixes`.
|
||||
Unknown parameters are rejected.
|
||||
|
||||
For separately built paper-doll equipment, add the reserved
|
||||
`paperdoll_layer` tag. Its pivot is intentionally inherited from the body and
|
||||
may lie far outside the small layer bbox; `sf validate` suppresses only that
|
||||
specific heuristic while still checking hashes, streams, frames and durations.
|
||||
Tags and palette metadata are excluded from the expensive backend-input hash,
|
||||
so reorganizing or retagging a library updates the catalog without rerendering
|
||||
unchanged Blender frames.
|
||||
20
docs/codegen-watch.md
Normal file
20
docs/codegen-watch.md
Normal file
@ -0,0 +1,20 @@
|
||||
# Code generation and hot reload
|
||||
|
||||
After building a library, generate a dependency-free C99/C++17 header:
|
||||
|
||||
```powershell
|
||||
sf codegen --build-dir build --output generated/spriteforge_assets.h
|
||||
```
|
||||
|
||||
The header contains FNV-1a 32-bit enums for assets, animations and palettes,
|
||||
plus a compact metadata table. Include it together with `sfa.h` or `sfa.hpp`.
|
||||
|
||||
Watch manifests and their declared `rig`/`source` files with:
|
||||
|
||||
```powershell
|
||||
sf watch assets/items.yaml assets/monsters.yaml --jobs 4
|
||||
```
|
||||
|
||||
The initial build and every successful rebuild atomically update
|
||||
`build/reload.json`. A game can poll `sequence` and reload only the IDs listed
|
||||
in `assets`. The signal is written after all build outputs are complete.
|
||||
48
docs/external-gpu.md
Normal file
48
docs/external-gpu.md
Normal file
@ -0,0 +1,48 @@
|
||||
# External GPU worker
|
||||
|
||||
SpriteForge uses ComfyUI's HTTP API as its first remote GPU protocol. The Studio
|
||||
and compiler stay on the game-development machine; only inputs and generated
|
||||
PNGs cross the network.
|
||||
|
||||
## Prepare ComfyUI
|
||||
|
||||
In ComfyUI, build and test a workflow, then choose **Save (API Format)**. The
|
||||
workflow JSON may contain these SpriteForge placeholders:
|
||||
|
||||
| Placeholder | Value |
|
||||
|---|---|
|
||||
| `{{PROMPT}}` | combined style, asset and shot prompt |
|
||||
| `{{NEGATIVE_PROMPT}}` | combined negative prompt |
|
||||
| `{{SEED}}` | deterministic integer seed |
|
||||
| `{{WIDTH}}`, `{{HEIGHT}}` | source canvas size |
|
||||
| `{{BATCH_SIZE}}` | requested candidate count |
|
||||
| `{{MODEL}}` | model/checkpoint label from Studio |
|
||||
| `{{STEPS}}`, `{{CFG}}`, `{{DENOISE}}` | diffusion controls |
|
||||
| `{{SAMPLER}}`, `{{SCHEDULER}}` | ComfyUI sampler names |
|
||||
| `{{REFERENCE_0}}` | uploaded canonical reference filename |
|
||||
| `{{CONTROL_CONTROL}}` | uploaded pose/depth control filename |
|
||||
| `{{CONTROL_MASK}}` | uploaded inpainting mask filename |
|
||||
| `{{CONTROL_PREVIOUS}}` | previous approved animation frame for low-denoise img2img |
|
||||
|
||||
Placeholders can replace a complete JSON value or occur inside a string. Use an
|
||||
IP-Adapter node for `REFERENCE_0`; use the appropriate ControlNet image loader
|
||||
for `CONTROL_CONTROL`; use `CONTROL_MASK` in the inpaint branch.
|
||||
|
||||
Configure the local client:
|
||||
|
||||
```powershell
|
||||
$null = sf gpu workflow-check C:\workflows\spriteforge_api.json
|
||||
$env:SF_GPU_TOKEN = "secret-if-the-server-needs-one"
|
||||
sf gpu add renderbox --url https://gpu.example --workflow C:\workflows\spriteforge_api.json --token-env SF_GPU_TOKEN
|
||||
sf gpu test renderbox
|
||||
sf studio C:\art\my_game --gpu-profile renderbox
|
||||
```
|
||||
|
||||
The profile stores the endpoint, workflow path and environment-variable name.
|
||||
It never stores the token. Use HTTPS or a private VPN/Tailscale network; an
|
||||
unprotected public ComfyUI endpoint allows arbitrary outsiders to consume the
|
||||
GPU.
|
||||
|
||||
The exact hosting vendor is intentionally irrelevant. A rented VM, a friend's
|
||||
machine, a future local NVIDIA GPU and a managed ComfyUI service all expose the
|
||||
same Studio workflow.
|
||||
44
docs/format-v1.md
Normal file
44
docs/format-v1.md
Normal file
@ -0,0 +1,44 @@
|
||||
# SpriteForge binary formats v1
|
||||
|
||||
All integers and IEEE-754 floats are little-endian. Every offset is an unsigned
|
||||
32-bit byte offset from the beginning of its file. Files over 4 GiB, big-endian
|
||||
hosts, and per-entry alpha are not supported by v1. Readers must bounds-check
|
||||
all arithmetic before dereferencing data.
|
||||
|
||||
## SFA (sprite frames)
|
||||
|
||||
The 64-byte header contains `SFA\0`, major/minor version, flags, file size,
|
||||
counts and offsets for strings, animation records, direction records, frame
|
||||
records, layer-order values and payload data. Flag bit 0 means normals exist,
|
||||
bit 1 means depth exists, and bit 31 is reserved for future palette alpha.
|
||||
|
||||
Animation names have both FNV-1a 32-bit IDs and NUL-terminated UTF-8 strings.
|
||||
Multiple animation records share the direction-angle table and each stores its
|
||||
own first frame and frames-per-direction range.
|
||||
Directions use signed centidegrees. A frame record stores uint16 dimensions,
|
||||
int16 pivot, uint16 duration in milliseconds, local `depth_min/depth_max` in
|
||||
world units, and offsets/sizes for three independent streams.
|
||||
|
||||
Each present stream starts with `height + 1` uint32 row offsets relative to the
|
||||
stream start. Thus a clipped row is found in O(1). A row consists of a uint16
|
||||
span count followed by spans. Each span starts with uint16 x and length, then:
|
||||
|
||||
- color: `length` palette-index bytes (index 0 is forbidden in spans);
|
||||
- normals: `length * 2` signed-normalized bytes, X right and Y up; positive Z
|
||||
is reconstructed as `sqrt(max(0, 1-x*x-y*y))`;
|
||||
- depth: `length` bytes, where 0 is locally nearest and 255 locally farthest.
|
||||
|
||||
Layer order is a frame-major array of uint16 layer indices. V1 expresses one
|
||||
permutation per frame; self-intersecting components must be split into passes.
|
||||
|
||||
## SFP (palette pack)
|
||||
|
||||
One SFP can hold named palettes and colormaps; multiple SFP views may coexist.
|
||||
A palette is exactly 256 RGB triples. Index 0 is transparent by convention and
|
||||
its RGB is ignored. Entries 1..255 are opaque. A colormap is a 256-byte index
|
||||
mapping. Blending is selected once per blit call (`opaque`, `alpha25`,
|
||||
`alpha50`, `alpha75`, `additive`, `subtractive`), never stored per entry.
|
||||
|
||||
Readers accept the same major version and a minor version no newer than theirs.
|
||||
Unknown flag bits are rejected except the reserved future-alpha bit, which v1
|
||||
readers must reject when set because they cannot interpret it.
|
||||
35
docs/game-integration.md
Normal file
35
docs/game-integration.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Game integration
|
||||
|
||||
Studio Export writes:
|
||||
|
||||
```text
|
||||
exports/
|
||||
<asset-id>.sfa
|
||||
palettes.sfp
|
||||
index.json
|
||||
spriteforge_assets.h
|
||||
reload.json
|
||||
```
|
||||
|
||||
The game includes `include/sfa.h` (or the C++17 RAII `include/sfa.hpp`) and the
|
||||
generated `spriteforge_assets.h`. Asset and animation identifiers are compile-
|
||||
time enums, not strings.
|
||||
|
||||
At development time the game watches `reload.json.sequence`. When it changes,
|
||||
reload the listed `.sfa` files and the palette pack at a safe frame boundary.
|
||||
Production builds copy the export directory into their normal data package.
|
||||
|
||||
The intended C++ call site is conceptually:
|
||||
|
||||
```cpp
|
||||
draw_sprite(SF_ASSET_FIELD_AGENT, SF_ANIM_WALK, direction, frame,
|
||||
world_x, world_y, palette, light);
|
||||
```
|
||||
|
||||
SpriteForge never edits or imports game code. The game supplies an export path,
|
||||
and its tiny runtime owns file I/O and hot-reload policy.
|
||||
|
||||
Equipment will use synchronized assets with identical animation/direction/frame
|
||||
coordinates and pivots: body, armour, weapon and effects are loaded separately
|
||||
and blitted in the exported layer order. This avoids baking every possible
|
||||
combination while keeping the generative authoring process reviewable.
|
||||
22
docs/import-composite.md
Normal file
22
docs/import-composite.md
Normal file
@ -0,0 +1,22 @@
|
||||
# Local import and composite backends
|
||||
|
||||
Import a PNG or row-major sprite sheet without any network service:
|
||||
|
||||
```yaml
|
||||
potion:
|
||||
backend: import
|
||||
source: source/potion_sheet.png
|
||||
params: {frame_width: 32, frame_height: 32, frames: 6}
|
||||
fps: 12
|
||||
```
|
||||
|
||||
Compose equally-sized PNG layers in back-to-front order:
|
||||
|
||||
```yaml
|
||||
armored_body:
|
||||
backend: composite
|
||||
layers: [source/body.png, source/armor.png, source/helmet.png]
|
||||
```
|
||||
|
||||
All paths are relative to the manifest. Source bytes participate in the cache
|
||||
key, and both backends feed the same crop, palette, normal and depth pipeline.
|
||||
31
docs/incremental-builds.md
Normal file
31
docs/incremental-builds.md
Normal file
@ -0,0 +1,31 @@
|
||||
# Incremental builds and cache
|
||||
|
||||
SpriteForge uses two immutable content-addressed cache layers under `.sfcache/`:
|
||||
|
||||
- `raw/<input_hash>.npz` stores expensive backend RGBA output;
|
||||
- `artifacts/<artifact_hash>.sfa` stores palette-dependent postprocessed output.
|
||||
|
||||
The input hash covers the normalized asset specification, every dependency file
|
||||
reported by the backend, the backend version, and the raw-cache format version.
|
||||
The artifact hash additionally covers the shared palette and postprocess
|
||||
version. Cache writes use a temporary file followed by an atomic replace.
|
||||
|
||||
This split is intentional: changing a palette may require cheap requantization,
|
||||
but must never rerun a future multi-hour Blender render.
|
||||
|
||||
```text
|
||||
sf build assets.yaml --jobs 8
|
||||
sf build assets.yaml --asset changed_actor --jobs 8
|
||||
sf build assets.yaml --rebuild-palette
|
||||
```
|
||||
|
||||
`--jobs N` runs independent backend tasks in a process pool on Windows and
|
||||
Linux. The CLI reports counts for newly generated raw assets, raw-cache hits,
|
||||
and final artifact-cache hits.
|
||||
|
||||
An existing `palettes.sfp` is stable across ordinary incremental builds. Use
|
||||
`--rebuild-palette` only with an unfiltered full build; SpriteForge rejects a
|
||||
filtered palette rebuild because it would invalidate unchanged indexed assets.
|
||||
|
||||
The cache is disposable and never a source of truth. Manifests and source files
|
||||
remain authoritative.
|
||||
34
docs/introspection.md
Normal file
34
docs/introspection.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Text-first introspection
|
||||
|
||||
Agents inspect generated libraries through commands, never by opening images.
|
||||
All commands default to `build/` and accept `--build-dir`.
|
||||
|
||||
```text
|
||||
sf list [--tag TAG] [--backend BACKEND]
|
||||
sf describe ASSET_ID
|
||||
sf ascii ASSET_ID [--frame N] [--columns 40]
|
||||
sf palette NAME
|
||||
sf validate [MANIFEST ...]
|
||||
sf diff ASSET_ID --against HASH_OR_PREFIX
|
||||
sf stats
|
||||
```
|
||||
|
||||
`list` reports ID, backend, frame/direction counts, dimensions, build hash and
|
||||
status. `describe` includes the complete normalized manifest specification.
|
||||
`ascii` reads only indexed silhouette data and produces a preview around forty
|
||||
columns wide. `validate` checks hashes, binary structure, empty frames,
|
||||
durations, pivots, metadata consistency, palettes, and manifest entries missing
|
||||
from the catalog.
|
||||
|
||||
Each build writes `index.json`. `hash` identifies the build inputs and tool
|
||||
versions; `content_hash` verifies the `.sfa` bytes. Text snapshots under
|
||||
`.sfmeta/` allow `diff` to explain changes without decoding images.
|
||||
|
||||
## Human-only contact sheets
|
||||
|
||||
```text
|
||||
sf contact-sheet --output contact-sheet.png
|
||||
```
|
||||
|
||||
This is explicitly for a human. An agent must not open the resulting PNG; it
|
||||
must use `sf validate` and `sf ascii` to inspect output.
|
||||
56
docs/manifest-v1.md
Normal file
56
docs/manifest-v1.md
Normal file
@ -0,0 +1,56 @@
|
||||
# Manifest schema v1
|
||||
|
||||
Manifests are UTF-8 YAML and may use the compact flat form:
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
defaults: {dirs: 8, fps: 12, pivot: bottom_center}
|
||||
|
||||
demo_actor:
|
||||
backend: blender
|
||||
rig: rigs/demo.blend
|
||||
anims: [idle, walk]
|
||||
|
||||
demo_floor:
|
||||
backend: procedural
|
||||
generator: floor_tile
|
||||
params: {color: '#706860', variants: 4}
|
||||
dirs: 1
|
||||
```
|
||||
|
||||
An explicit `assets:` mapping is also supported, but it cannot be mixed with
|
||||
flat asset declarations. Asset IDs and backend names match
|
||||
`[a-z][a-z0-9_]*`. Unknown fields are errors, while generator-specific options
|
||||
belong under `params`.
|
||||
|
||||
Common fields are `backend`, `dirs`, `fps`, `pivot`, `size`, `palette`,
|
||||
`palettes`, `tags`, `seed`, and `postprocess_scale`. A pivot is a standard name
|
||||
(`bottom_center`, `center`, `bottom_left`, `top_left`) or signed `x,y`.
|
||||
Sizes use `WIDTHxHEIGHT`.
|
||||
|
||||
`postprocess_scale` applies a fixed nearest-neighbour scale before tight alpha
|
||||
cropping and target-size fitting. Set it to `0.25` for a 4x supersampled
|
||||
Blender render. Unlike adaptive bbox fitting, this preserves the relative world
|
||||
scale of short and tall assets.
|
||||
|
||||
Backend contracts:
|
||||
|
||||
| Backend | Required field |
|
||||
|---|---|
|
||||
| `procedural` | `generator` |
|
||||
| `blender` | `rig` |
|
||||
| `import` | `source` |
|
||||
| `composite` | non-empty `layers` |
|
||||
|
||||
Additional backends register a contract through
|
||||
`register_backend_contract()` and put backend-specific configuration in
|
||||
`params`; neither the loader nor the manifest core needs modification.
|
||||
|
||||
Validate without producing assets:
|
||||
|
||||
```text
|
||||
sf validate assets/environment.yaml assets/actors.yaml
|
||||
sf build assets/environment.yaml --tag environment --jobs 4 --dry-run
|
||||
```
|
||||
|
||||
Diagnostics use `absolute-file:line:column` followed by the YAML property path.
|
||||
137
docs/procedural.md
Normal file
137
docs/procedural.md
Normal file
@ -0,0 +1,137 @@
|
||||
# Procedural backend
|
||||
|
||||
`procedural` is deterministic: identical manifest data and seed produce
|
||||
byte-identical RGBA frames. Variant `n` derives a stable RNG stream from the
|
||||
asset seed. Unknown parameters fail the build.
|
||||
|
||||
The backend serves two kinds of generators, selected by the manifest
|
||||
`generator` name. A name may live in only one of the two registries, so the
|
||||
choice is never ambiguous.
|
||||
|
||||
| kind | signature | `dirs` | returns |
|
||||
|---|---|---|---|
|
||||
| single frame | `(params, rng) -> (rgba, (pivot_x, pivot_y))` | must be `1` | one image per variant |
|
||||
| sequence | `(spec, rng) -> RawAsset` | any `1..32` | frames, directions, animations |
|
||||
|
||||
## Single-frame generators
|
||||
|
||||
```python
|
||||
from spriteforge.backends.procedural import register_generator
|
||||
|
||||
register_generator("floor_tile", floor_tile)
|
||||
```
|
||||
|
||||
The backend repeats the generator `params.variants` times, one frame per
|
||||
variant, with `duration_ms` taken from `fps`, one direction and the animation
|
||||
`default`.
|
||||
|
||||
### `floor_tile`
|
||||
|
||||
Isometric diamond. Parameters: `width` (64), `height` (width/2), `color`,
|
||||
`edge_color`, `noise`, `variants`, `dither`.
|
||||
|
||||
### `wall_tile`
|
||||
|
||||
Isometric top plus two vertical faces. Parameters: `width` (64), `top_height`
|
||||
(width/2), `height` (64), `top_color`, `left_color`, `right_color`, `variants`,
|
||||
`dither`.
|
||||
|
||||
### `decal`
|
||||
|
||||
Irregular soft ellipse. Parameters: `width` (48), `height` (24), `color`,
|
||||
`irregularity`, `variants`, `dither`.
|
||||
|
||||
Colors are `#RRGGBB` or `#RRGGBBAA`.
|
||||
|
||||
## Sequence generators
|
||||
|
||||
A sequence generator owns its whole asset: it receives the validated
|
||||
`AssetSpec` (so `dirs`, `fps`, `pivot`, `size`, `anims`, `params` and `seed` are
|
||||
all visible) plus one seeded `numpy.random.Generator`, and returns a `RawAsset`.
|
||||
This is the shape used by animated multi-direction sprites such as characters.
|
||||
|
||||
```python
|
||||
from spriteforge.backends.procedural import register_sequence_generator
|
||||
from spriteforge.backends.sequence import (build_sequence_asset, direction_centidegrees,
|
||||
duration_from_fps, resolve_pivot)
|
||||
|
||||
def walker(spec, rng):
|
||||
directions = direction_centidegrees(spec.dirs)
|
||||
duration = duration_from_fps(spec.fps)
|
||||
animations = {}
|
||||
for name in spec.anims:
|
||||
animations[name] = [[draw(name, direction, frame, duration, rng)
|
||||
for frame in range(4)]
|
||||
for direction in range(len(directions))]
|
||||
return build_sequence_asset(animations, directions)
|
||||
|
||||
register_sequence_generator("walker", walker)
|
||||
```
|
||||
|
||||
```yaml
|
||||
hero:
|
||||
backend: procedural
|
||||
generator: walker
|
||||
dirs: 8
|
||||
fps: 12
|
||||
anims: [idle, walk, attack]
|
||||
size: 48x64
|
||||
params: {palette: bone}
|
||||
```
|
||||
|
||||
Registration happens at import time, so the module that calls
|
||||
`register_sequence_generator` must be imported by the package — add it to
|
||||
`spriteforge/backends/__init__.py`. Otherwise `sf build` reports an unknown
|
||||
generator and lists the registered names of both kinds.
|
||||
|
||||
The result is checked against the manifest before anything is encoded: the
|
||||
direction count must equal `dirs`, animation names must be unique and non-empty,
|
||||
every animation requested in `anims` must be present, and the frame count must
|
||||
equal what the animation table describes. Failures name the generator.
|
||||
|
||||
Everything downstream is unchanged: the common postprocess thresholds alpha,
|
||||
crops each frame while preserving its pivot, optionally scales down to manifest
|
||||
`size`, builds one median-cut RGB palette, optionally applies ordered dithering,
|
||||
and generates estimated XY normals and local depth. Cropping means frames of one
|
||||
animation differ in size, so the runtime aligns them by pivot, never by corner.
|
||||
|
||||
### Directions and animations
|
||||
|
||||
`direction_centidegrees(count)` returns `count` evenly spaced angles in
|
||||
hundredths of a degree, starting at `0` — the same table the Blender driver
|
||||
emits, so an asset keeps facing the same way whichever backend built it.
|
||||
|
||||
`build_sequence_asset(animations, directions)` takes
|
||||
`{animation_name: [direction][frame]}` and lays the frames out the way the `.sfa`
|
||||
format expects: animations are stored back to back, and inside one animation the
|
||||
order is direction-major, that is all frames of direction 0, then direction 1,
|
||||
and so on.
|
||||
|
||||
```text
|
||||
index = animation.first_frame + direction * animation.frames_per_direction + frame
|
||||
```
|
||||
|
||||
That formula lives in exactly one function, `sequence.frame_index`, used both by
|
||||
the layout code and by the tests. A generator must never index frames by hand:
|
||||
a wrong order does not fail the build, it silently ships sprites that face the
|
||||
wrong way. Animation order in the file follows the key order of the mapping, and
|
||||
the first key becomes the asset's default animation.
|
||||
|
||||
Optional arguments mirror the format: `layer_count` with one `layer_orders`
|
||||
permutation per frame, and `warnings` surfaced by `sf validate`.
|
||||
|
||||
### Drawing helpers
|
||||
|
||||
`spriteforge.pixel` is a backend-agnostic pixel-art toolbox over `HxWx4` uint8
|
||||
arrays — the same buffer `RawFrame` expects. It has canvas and fill, bounds
|
||||
checked pixels, Bresenham lines, thick lines, rectangles, ellipses, capsules for
|
||||
limbs, silhouette outlining, colour shading and three-step ramps, a directional
|
||||
rim light, and depth-sorted limbs that darken the far arm and leg. Randomness is
|
||||
never global: every noisy helper takes the `numpy.random.Generator` handed to the
|
||||
generator, which is what keeps builds reproducible.
|
||||
|
||||
```text
|
||||
sf build assets/environment.yaml --backend procedural --output build
|
||||
```
|
||||
|
||||
The result is one `.sfa` per asset, `palettes.sfp`, and a compact `index.json`.
|
||||
75
docs/pull-relay.md
Normal file
75
docs/pull-relay.md
Normal file
@ -0,0 +1,75 @@
|
||||
# Pull relay: no VPN and no inbound GPU port
|
||||
|
||||
The relay topology is:
|
||||
|
||||
```text
|
||||
SpriteForge Studio --short outbound HTTPS--> public relay
|
||||
GPU worker --short outbound HTTPS--> public relay
|
||||
GPU worker --localhost HTTP--------> ComfyUI
|
||||
```
|
||||
|
||||
There is no Tailscale, port forwarding, direct connection between the two PCs,
|
||||
WebSocket or permanently open request. The worker performs a short poll when it
|
||||
is idle. During generation it periodically sends a short heartbeat so a crashed
|
||||
worker's lease can be recovered.
|
||||
|
||||
The relay needs a small public host because two NATed computers cannot discover
|
||||
each other without an intermediary. It does not need a GPU. SQLite and a Docker
|
||||
volume are sufficient. Inputs, workflows and outputs are content-addressed by
|
||||
SHA-256 and expired completed jobs are eligible for cleanup after 72 hours.
|
||||
|
||||
## Deploy the relay on a VPS
|
||||
|
||||
Copy the SpriteForge repository to a server with Docker and a DNS name pointing
|
||||
to it. In `deploy/relay`, create `.env`:
|
||||
|
||||
```dotenv
|
||||
RELAY_DOMAIN=sprite-relay.example.com
|
||||
SF_RELAY_CLIENT_TOKEN=long-random-client-secret
|
||||
SF_RELAY_WORKER_TOKEN=different-long-random-worker-secret
|
||||
```
|
||||
|
||||
Generate secrets locally with `python -c "import secrets; print(secrets.token_urlsafe(48))"`.
|
||||
Start it:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
curl https://sprite-relay.example.com/health
|
||||
```
|
||||
|
||||
Caddy obtains and renews TLS automatically. Never reuse the client token as the
|
||||
worker token, commit `.env`, or expose the relay over plaintext Internet HTTP.
|
||||
|
||||
## Start the GPU computer
|
||||
|
||||
ComfyUI remains local and should show `http://127.0.0.1:8188`. On the GPU PC,
|
||||
install SpriteForge (a checkout or wheel), then:
|
||||
|
||||
```powershell
|
||||
$env:SF_RELAY_WORKER_TOKEN = "worker secret"
|
||||
sf relay worker `
|
||||
--url https://sprite-relay.example.com `
|
||||
--comfy-url http://127.0.0.1:8188
|
||||
```
|
||||
|
||||
The terminal may stay minimized. It creates no listening port. `--once` leases
|
||||
at most one job and exits, which is useful for diagnostics or Task Scheduler.
|
||||
|
||||
## Connect Studio
|
||||
|
||||
On the development PC:
|
||||
|
||||
```powershell
|
||||
$env:SF_RELAY_CLIENT_TOKEN = "client secret"
|
||||
sf gpu add renderbox `
|
||||
--kind relay `
|
||||
--url https://sprite-relay.example.com `
|
||||
--workflow C:\Users\uuu\Documents\spriteforge\examples\comfyui\txt2img_api.json `
|
||||
--token-env SF_RELAY_CLIENT_TOKEN `
|
||||
--timeout 1800
|
||||
sf gpu test renderbox
|
||||
sf studio C:\art\my_game --gpu-profile renderbox
|
||||
```
|
||||
|
||||
`gpu test` checks only the relay. A real Pipeline/Generate job verifies the GPU
|
||||
worker and local ComfyUI.
|
||||
50
docs/studio-architecture.md
Normal file
50
docs/studio-architecture.md
Normal file
@ -0,0 +1,50 @@
|
||||
# SpriteForge Studio
|
||||
|
||||
SpriteForge has two deliberately separate halves:
|
||||
|
||||
1. **Studio** is the human-facing authoring system. It stores art direction,
|
||||
references, generation recipes, candidates, decisions and revision history.
|
||||
2. **Compiler** is the existing deterministic pipeline. It converts approved
|
||||
RGBA frames into palettes and `.sfa` files for the game.
|
||||
|
||||
The separation keeps generation providers replaceable. A Studio project never
|
||||
depends on a particular hosted API or model.
|
||||
|
||||
## Project layout
|
||||
|
||||
```text
|
||||
my-art/
|
||||
project.json # compact, versioned source of truth
|
||||
media/ab/cd/<sha256>.png # content-addressed source and candidate images
|
||||
masks/ # editable alpha/region masks (future)
|
||||
exports/ # approved RGBA sequences for the compiler
|
||||
```
|
||||
|
||||
`project.json` contains no embedded images. Every state-changing operation adds
|
||||
an event with a timestamp and enough metadata to audit the decision. Generation
|
||||
recipes record provider, model, seed, prompts, dimensions, references and
|
||||
control inputs so accepted art can be reproduced.
|
||||
|
||||
## Generation boundary
|
||||
|
||||
A provider receives a provider-neutral `GenerationRequest` and yields one or
|
||||
more PNG candidates. Initial providers are expected to be remote because Intel
|
||||
Iris Xe is suitable for running the Studio but not for interactive diffusion.
|
||||
Likely adapters are a remote ComfyUI worker and a hosted image API. Providers
|
||||
are plugins and must not be referenced by the project store or compiler.
|
||||
|
||||
Large sprites are authored on a source canvas up to 1024x1024 and may export to
|
||||
an in-game box as large as 600x600. Small units are also authored at 256x256 or
|
||||
512x512 and downsampled only during export; diffusion is never asked to paint
|
||||
directly at 60x60.
|
||||
|
||||
## Human control loop
|
||||
|
||||
The primary unit of work is a shot: asset + animation + direction + frame.
|
||||
Each shot may have many candidates but at most one approved candidate. Reject,
|
||||
approve and supersede operations retain history. Batch generation is an
|
||||
accelerator, not an irreversible action.
|
||||
|
||||
The first production gate is intentionally small: one approved reference, one
|
||||
direction and four idle frames. The system should not be scaled to a complete
|
||||
character until those frames preserve identity, silhouette and equipment.
|
||||
52
docs/studio.md
Normal file
52
docs/studio.md
Normal file
@ -0,0 +1,52 @@
|
||||
# SpriteForge Studio workflow
|
||||
|
||||
Studio is a local browser application. Images and project decisions stay in a
|
||||
chosen project directory; API tokens are never stored there.
|
||||
|
||||
```powershell
|
||||
pip install -e .
|
||||
sf studio C:\art\my_game --init --id my_game --name "My Game"
|
||||
```
|
||||
|
||||
The normal authoring loop is:
|
||||
|
||||
1. Write the global style bible once.
|
||||
2. Create an asset and set its generation canvas and final in-game box.
|
||||
3. Add one or more approved appearance references.
|
||||
4. Create an animation grid (for example 8 directions and 6 frames).
|
||||
5. Add pose/depth controls, generate candidates and approve one per shot.
|
||||
6. Use an inpainting mask to regenerate only a bad region. White pixels are
|
||||
editable; black pixels are protected.
|
||||
7. Set alpha threshold/chroma key if required, adjust normalized pivot, Export.
|
||||
|
||||
Use **Approved preview** to play a selected animation and direction. Assets in
|
||||
the same layer group are composited by `layer_order`, so body/armour/weapon
|
||||
alignment can be checked before export. When frame N is generated, the approved
|
||||
frame N-1 is automatically exposed to ComfyUI as `CONTROL_PREVIOUS` for a
|
||||
low-denoise temporal img2img branch.
|
||||
|
||||
Every edit creates a content-addressed project revision. **Revision history**
|
||||
can restore an earlier state without destroying the current one. Cloning an
|
||||
asset copies its references, settings, controls and complete shot grid but not
|
||||
its candidates; this is the normal way to create synchronized equipment layers.
|
||||
|
||||
Run validation from the UI or without images:
|
||||
|
||||
```powershell
|
||||
sf studio-validate C:\art\my_game
|
||||
```
|
||||
|
||||
The report covers missing approvals, ragged grids, broken media, empty
|
||||
silhouettes, strong temporal silhouette drift, failed jobs and incompatible
|
||||
paperdoll layers.
|
||||
|
||||
`Pipeline test` produces labelled cards without a GPU. It exists to verify the
|
||||
complete queue, review, persistence and export path and is not an art backend.
|
||||
|
||||
Generation source canvases should normally be 512x512. A 60x60 unit is reduced
|
||||
only during export. Large bosses may use 768x768 or 1024x1024 sources and export
|
||||
up to a 600x600 in-game box.
|
||||
|
||||
Export refuses incomplete rectangular animation grids. Every animation must
|
||||
have one approved candidate for every direction and frame. This prevents a
|
||||
missing direction from silently becoming the wrong runtime frame.
|
||||
22
examples/comfyui/README.md
Normal file
22
examples/comfyui/README.md
Normal file
@ -0,0 +1,22 @@
|
||||
# ComfyUI workflows
|
||||
|
||||
`txt2img_api.json` uses only built-in ComfyUI nodes. It is a connection and
|
||||
round-trip baseline, not the production sprite workflow. Set Studio's Model
|
||||
field to the exact checkpoint filename installed on the worker.
|
||||
|
||||
Validate it locally:
|
||||
|
||||
```powershell
|
||||
sf gpu workflow-check examples\comfyui\txt2img_api.json
|
||||
```
|
||||
|
||||
The production workflow should be exported from ComfyUI in API format and add:
|
||||
|
||||
- IP-Adapter/reference conditioning fed by `{{REFERENCE_0}}`;
|
||||
- pose/depth ControlNet fed by `{{CONTROL_CONTROL}}`;
|
||||
- an inpaint branch fed by `{{CONTROL_MASK}}`;
|
||||
- low-denoise previous-frame img2img fed by `{{CONTROL_PREVIOUS}}`;
|
||||
- alpha/background removal before `SaveImage` where supported.
|
||||
|
||||
Keep the standard SpriteForge placeholders in the appropriate node inputs, then
|
||||
run `sf gpu workflow-check` again.
|
||||
41
examples/comfyui/txt2img_api.json
Normal file
41
examples/comfyui/txt2img_api.json
Normal file
@ -0,0 +1,41 @@
|
||||
{
|
||||
"1": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": "{{MODEL}}"}
|
||||
},
|
||||
"2": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "{{PROMPT}}", "clip": ["1", 1]}
|
||||
},
|
||||
"3": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "{{NEGATIVE_PROMPT}}", "clip": ["1", 1]}
|
||||
},
|
||||
"4": {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": "{{WIDTH}}", "height": "{{HEIGHT}}", "batch_size": "{{BATCH_SIZE}}"}
|
||||
},
|
||||
"5": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": "{{SEED}}",
|
||||
"steps": "{{STEPS}}",
|
||||
"cfg": "{{CFG}}",
|
||||
"sampler_name": "{{SAMPLER}}",
|
||||
"scheduler": "{{SCHEDULER}}",
|
||||
"denoise": "{{DENOISE}}",
|
||||
"model": ["1", 0],
|
||||
"positive": ["2", 0],
|
||||
"negative": ["3", 0],
|
||||
"latent_image": ["4", 0]
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["5", 0], "vae": ["1", 2]}
|
||||
},
|
||||
"7": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": "SpriteForge", "images": ["6", 0]}
|
||||
}
|
||||
}
|
||||
20
examples/manifests/showcase.yaml
Normal file
20
examples/manifests/showcase.yaml
Normal file
@ -0,0 +1,20 @@
|
||||
version: 1
|
||||
defaults:
|
||||
dirs: 8
|
||||
fps: 12
|
||||
pivot: bottom_center
|
||||
|
||||
demo_floor:
|
||||
backend: procedural
|
||||
generator: floor_tile
|
||||
params: {color: '#706860', edge_color: '#484440', variants: 4}
|
||||
dirs: 1
|
||||
tags: [environment]
|
||||
|
||||
demo_actor:
|
||||
backend: blender
|
||||
rig: rigs/demo.blend
|
||||
parts: {body: body_mesh, head: head_mesh}
|
||||
anims: [idle, walk]
|
||||
palettes: [default, alternate]
|
||||
tags: [actor]
|
||||
25
examples/raylib_cpp17/README.md
Normal file
25
examples/raylib_cpp17/README.md
Normal file
@ -0,0 +1,25 @@
|
||||
# raylib C++17 example
|
||||
|
||||
This is isolated from every consuming game and renders into a 480x270 CPU
|
||||
framebuffer before one nearest-neighbour raylib blit.
|
||||
|
||||
```powershell
|
||||
python tests/make_fixture.py build/test.sfa
|
||||
cmake -S . -B build -DSPRITEFORGE_BUILD_RAYLIB_EXAMPLE=ON
|
||||
cmake --build build --config Release
|
||||
build\Release\spriteforge_raylib_example.exe build\test.sfa
|
||||
```
|
||||
|
||||
The example requires an already installed raylib 6 package and never modifies
|
||||
the current game repository.
|
||||
|
||||
For use from another repository:
|
||||
|
||||
```powershell
|
||||
pip install -e C:\Users\uuu\Documents\spriteforge
|
||||
sf build assets\sprites.yaml --output build\sprites
|
||||
sf codegen --build-dir build\sprites --output generated\spriteforge_assets.h
|
||||
```
|
||||
|
||||
Add SpriteForge's `include/` and the game's `generated/` directory to include
|
||||
paths. The example conditionally includes the generated header when present.
|
||||
26
examples/raylib_cpp17/main.cpp
Normal file
26
examples/raylib_cpp17/main.cpp
Normal file
@ -0,0 +1,26 @@
|
||||
#include "sfa.hpp"
|
||||
#if __has_include("spriteforge_assets.h")
|
||||
#include "spriteforge_assets.h"
|
||||
#endif
|
||||
#include <raylib.h>
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
|
||||
int main(int argc,char** argv){
|
||||
if(argc!=2){std::fprintf(stderr,"usage: spriteforge_raylib_example sprite.sfa\n");return 2;}
|
||||
spriteforge::File file(argv[1]); auto frame=file.frame(0);
|
||||
std::array<uint8_t,768> palette{};
|
||||
palette[3]=220; palette[4]=65; palette[5]=55;
|
||||
palette[6]=80; palette[7]=180; palette[8]=220;
|
||||
palette[9]=235; palette[10]=205; palette[11]=95;
|
||||
std::array<uint32_t,480*270> pixels{};
|
||||
sfa_surface surface{pixels.data(),480,270,480};
|
||||
sfa_blit(&file.view(),&frame,palette.data(),nullptr,surface,240,140,SFA_BLEND_OPAQUE);
|
||||
InitWindow(1440,810,"SpriteForge SFA v1 raylib example");
|
||||
Image image{pixels.data(),480,270,1,PIXELFORMAT_UNCOMPRESSED_R8G8B8A8};
|
||||
Texture2D texture=LoadTextureFromImage(image); SetTextureFilter(texture,TEXTURE_FILTER_POINT);
|
||||
while(!WindowShouldClose()){UpdateTexture(texture,pixels.data());BeginDrawing();ClearBackground(BLACK);
|
||||
DrawTexturePro(texture,{0,0,480,270},{0,0,1440,810},{0,0},0,WHITE);EndDrawing();}
|
||||
UnloadTexture(texture);CloseWindow();return 0;
|
||||
}
|
||||
159
include/sfa.h
Normal file
159
include/sfa.h
Normal file
@ -0,0 +1,159 @@
|
||||
#ifndef SPRITEFORGE_SFA_H
|
||||
#define SPRITEFORGE_SFA_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define SFA_VERSION_MAJOR 1u
|
||||
#define SFA_VERSION_MINOR 0u
|
||||
#define SFA_FLAG_NORMALS 1u
|
||||
#define SFA_FLAG_DEPTH 2u
|
||||
#define SFA_FLAG_PALETTE_ALPHA 0x80000000u
|
||||
|
||||
typedef enum sfa_result { SFA_OK=0, SFA_BAD_ARGUMENT, SFA_TRUNCATED, SFA_BAD_MAGIC,
|
||||
SFA_UNSUPPORTED_VERSION, SFA_UNSUPPORTED_FLAGS, SFA_CORRUPT, SFA_NO_MEMORY, SFA_IO_ERROR } sfa_result;
|
||||
typedef enum sfa_blend_mode { SFA_BLEND_OPAQUE=0, SFA_BLEND_ALPHA25, SFA_BLEND_ALPHA50,
|
||||
SFA_BLEND_ALPHA75, SFA_BLEND_ADDITIVE, SFA_BLEND_SUBTRACTIVE } sfa_blend_mode;
|
||||
typedef struct sfa_view { const uint8_t* data; size_t size; uint32_t flags, frame_count;
|
||||
uint16_t animation_count, direction_count, layer_count; uint32_t strings_offset,animations_offset,directions_offset,frames_offset,layer_order_offset; } sfa_view;
|
||||
typedef struct sfa_animation { uint32_t name_hash,first_frame;uint16_t frames_per_direction,direction_count;const char* name; } sfa_animation;
|
||||
typedef struct sfa_direction { int16_t centidegrees;uint32_t first_frame; } sfa_direction;
|
||||
typedef struct sfa_frame { uint16_t width,height; int16_t pivot_x,pivot_y; uint16_t duration_ms,flags;
|
||||
float depth_min,depth_max; uint32_t offset[3],size[3]; } sfa_frame;
|
||||
typedef struct sfa_owned { uint8_t* data; size_t size; sfa_view view; } sfa_owned;
|
||||
typedef struct sfa_surface { uint32_t* pixels; int width,height,stride; } sfa_surface;
|
||||
typedef struct sfa_light { float x,y,z; uint8_t ambient; } sfa_light;
|
||||
|
||||
static uint16_t sfa_u16(const uint8_t* p) { return (uint16_t)(p[0]|((uint16_t)p[1]<<8)); }
|
||||
static int16_t sfa_i16(const uint8_t* p) { return (int16_t)sfa_u16(p); }
|
||||
static uint32_t sfa_u32(const uint8_t* p) { return (uint32_t)p[0]|((uint32_t)p[1]<<8)|((uint32_t)p[2]<<16)|((uint32_t)p[3]<<24); }
|
||||
static float sfa_f32(const uint8_t* p) { uint32_t u=sfa_u32(p); float f; memcpy(&f,&u,4); return f; }
|
||||
static int sfa_range(size_t n, uint32_t off, size_t amount) { return off<=n && amount<=n-off; }
|
||||
|
||||
static sfa_result sfa_open(const void* bytes, size_t size, sfa_view* out) {
|
||||
const uint8_t* p=(const uint8_t*)bytes; uint32_t file_size, known=3u;
|
||||
if(!p||!out) return SFA_BAD_ARGUMENT; if(size<64) return SFA_TRUNCATED;
|
||||
if(memcmp(p,"SFA\0",4)) return SFA_BAD_MAGIC;
|
||||
if(sfa_u16(p+4)!=1 || sfa_u16(p+6)>0) return SFA_UNSUPPORTED_VERSION;
|
||||
out->flags=sfa_u32(p+8); if((out->flags & ~known)!=0) return SFA_UNSUPPORTED_FLAGS;
|
||||
file_size=sfa_u32(p+12); if(file_size!=size) return SFA_CORRUPT;
|
||||
out->animation_count=sfa_u16(p+16); out->direction_count=sfa_u16(p+18);
|
||||
out->frame_count=sfa_u32(p+20); out->layer_count=sfa_u16(p+24);
|
||||
out->strings_offset=sfa_u32(p+28);out->animations_offset=sfa_u32(p+32);out->directions_offset=sfa_u32(p+36);
|
||||
out->frames_offset=sfa_u32(p+40); out->layer_order_offset=sfa_u32(p+44);
|
||||
if(!sfa_range(size,out->animations_offset,(size_t)out->animation_count*16u))return SFA_CORRUPT;
|
||||
if(!sfa_range(size,out->directions_offset,(size_t)out->direction_count*8u))return SFA_CORRUPT;
|
||||
if(!sfa_range(size,out->frames_offset,(size_t)out->frame_count*48u)) return SFA_CORRUPT;
|
||||
if(!sfa_range(size,out->layer_order_offset,(size_t)out->frame_count*out->layer_count*2u)) return SFA_CORRUPT;
|
||||
out->data=p; out->size=size; return SFA_OK;
|
||||
}
|
||||
|
||||
static sfa_result sfa_get_animation(const sfa_view* v,uint16_t index,sfa_animation* out){
|
||||
const uint8_t* p;uint32_t name_offset,absolute;const uint8_t* end;
|
||||
if(!v||!out||index>=v->animation_count)return SFA_BAD_ARGUMENT;p=v->data+v->animations_offset+(size_t)index*16u;
|
||||
out->name_hash=sfa_u32(p);name_offset=sfa_u32(p+4);out->first_frame=sfa_u32(p+8);out->frames_per_direction=sfa_u16(p+12);out->direction_count=sfa_u16(p+14);
|
||||
absolute=v->strings_offset+name_offset;if(absolute<v->strings_offset||absolute>=v->animations_offset)return SFA_CORRUPT;
|
||||
end=(const uint8_t*)memchr(v->data+absolute,0,v->animations_offset-absolute);if(!end)return SFA_CORRUPT;out->name=(const char*)(v->data+absolute);return SFA_OK;
|
||||
}
|
||||
static sfa_result sfa_get_direction(const sfa_view* v,uint16_t index,sfa_direction* out){
|
||||
const uint8_t* p;if(!v||!out||index>=v->direction_count)return SFA_BAD_ARGUMENT;p=v->data+v->directions_offset+(size_t)index*8u;
|
||||
out->centidegrees=sfa_i16(p);if(sfa_u16(p+2)!=0)return SFA_CORRUPT;out->first_frame=sfa_u32(p+4);return SFA_OK;
|
||||
}
|
||||
static sfa_result sfa_get_layer_order(const sfa_view* v,uint32_t frame_index,uint16_t position,uint16_t* layer){
|
||||
size_t index;if(!v||!layer||frame_index>=v->frame_count||position>=v->layer_count)return SFA_BAD_ARGUMENT;
|
||||
index=(size_t)frame_index*v->layer_count+position;*layer=sfa_u16(v->data+v->layer_order_offset+index*2u);if(*layer>=v->layer_count)return SFA_CORRUPT;return SFA_OK;
|
||||
}
|
||||
|
||||
static sfa_result sfa_get_frame(const sfa_view* v, uint32_t index, sfa_frame* f) {
|
||||
const uint8_t* p; int i; if(!v||!f||index>=v->frame_count) return SFA_BAD_ARGUMENT;
|
||||
p=v->data+v->frames_offset+(size_t)index*48u;
|
||||
f->width=sfa_u16(p); f->height=sfa_u16(p+2); f->pivot_x=sfa_i16(p+4); f->pivot_y=sfa_i16(p+6);
|
||||
f->duration_ms=sfa_u16(p+8); f->flags=sfa_u16(p+10); f->depth_min=sfa_f32(p+12); f->depth_max=sfa_f32(p+16);
|
||||
for(i=0;i<3;i++){ f->offset[i]=sfa_u32(p+20+i*8); f->size[i]=sfa_u32(p+24+i*8);
|
||||
if(f->offset[i] && !sfa_range(v->size,f->offset[i],f->size[i])) return SFA_CORRUPT; }
|
||||
return SFA_OK;
|
||||
}
|
||||
|
||||
static uint32_t sfa_mix(uint32_t d,uint32_t s,sfa_blend_mode m){
|
||||
unsigned dr=d&255,dg=(d>>8)&255,db=(d>>16)&255,sr=s&255,sg=(s>>8)&255,sb=(s>>16)&255,a;
|
||||
if(m==SFA_BLEND_OPAQUE)return s|0xff000000u;
|
||||
if(m==SFA_BLEND_ADDITIVE){sr=dr+sr>255?255:dr+sr;sg=dg+sg>255?255:dg+sg;sb=db+sb>255?255:db+sb;}
|
||||
else if(m==SFA_BLEND_SUBTRACTIVE){sr=dr>sr?dr-sr:0;sg=dg>sg?dg-sg:0;sb=db>sb?db-sb:0;}
|
||||
else {a=m==SFA_BLEND_ALPHA25?64u:m==SFA_BLEND_ALPHA50?128u:192u;sr=(dr*(256-a)+sr*a)>>8;sg=(dg*(256-a)+sg*a)>>8;sb=(db*(256-a)+sb*a)>>8;}
|
||||
return 0xff000000u|sr|(sg<<8)|(sb<<16);
|
||||
}
|
||||
|
||||
static sfa_result sfa_blit(const sfa_view* v,const sfa_frame* f,const uint8_t rgb[768],const uint8_t* map,
|
||||
sfa_surface dst,int anchor_x,int anchor_y,sfa_blend_mode mode){
|
||||
int sy,dy,x0=anchor_x-f->pivot_x,y0=anchor_y-f->pivot_y; const uint8_t* base; uint32_t table;
|
||||
if(!v||!f||!rgb||!dst.pixels||dst.stride<dst.width||!f->offset[0])return SFA_BAD_ARGUMENT;
|
||||
base=v->data+f->offset[0]; table=4u*((uint32_t)f->height+1u); if(f->size[0]<table)return SFA_CORRUPT;
|
||||
for(sy=0;sy<(int)f->height;sy++){const uint8_t *p,*end;uint16_t n,i;dy=y0+sy;if(dy<0||dy>=dst.height)continue;
|
||||
{uint32_t a=sfa_u32(base+sy*4),b=sfa_u32(base+(sy+1)*4);if(a>b||b>f->size[0]||b-a<2)return SFA_CORRUPT;p=base+a;end=base+b;}
|
||||
n=sfa_u16(p);p+=2;for(i=0;i<n;i++){uint16_t sx,len,j;if((size_t)(end-p)<4)return SFA_CORRUPT;sx=sfa_u16(p);len=sfa_u16(p+2);p+=4;if((size_t)(end-p)<len)return SFA_CORRUPT;
|
||||
for(j=0;j<len;j++){int dx=x0+sx+j;if(dx>=0&&dx<dst.width){uint8_t k=map?map[p[j]]:p[j];uint32_t c=rgb[k*3]|((uint32_t)rgb[k*3+1]<<8)|((uint32_t)rgb[k*3+2]<<16);dst.pixels[(size_t)dy*dst.stride+dx]=sfa_mix(dst.pixels[(size_t)dy*dst.stride+dx],c,mode);}}p+=len;}
|
||||
}return SFA_OK;
|
||||
}
|
||||
|
||||
typedef struct sfp_view { const uint8_t* data; size_t size; uint32_t palette_count, colormap_count, records_offset, colormap_records_offset; } sfp_view;
|
||||
|
||||
static sfa_result sfp_open(const void* bytes,size_t size,sfp_view* out){
|
||||
const uint8_t* p=(const uint8_t*)bytes;uint32_t file_size;
|
||||
if(!p||!out)return SFA_BAD_ARGUMENT;if(size<40)return SFA_TRUNCATED;
|
||||
if(memcmp(p,"SFP\0",4))return SFA_BAD_MAGIC;
|
||||
if(sfa_u16(p+4)!=1||sfa_u16(p+6)>0)return SFA_UNSUPPORTED_VERSION;
|
||||
if(sfa_u32(p+8)!=0)return SFA_UNSUPPORTED_FLAGS;
|
||||
file_size=sfa_u32(p+12);if(file_size!=size)return SFA_CORRUPT;
|
||||
out->palette_count=sfa_u32(p+16);out->colormap_count=sfa_u32(p+20);out->records_offset=sfa_u32(p+28);out->colormap_records_offset=sfa_u32(p+32);
|
||||
if(!sfa_range(size,out->records_offset,(size_t)out->palette_count*20u)||!sfa_range(size,out->colormap_records_offset,(size_t)out->colormap_count*20u))return SFA_CORRUPT;
|
||||
out->data=p;out->size=size;return SFA_OK;
|
||||
}
|
||||
|
||||
static sfa_result sfp_palette(const sfp_view* v,uint32_t index,const uint8_t** rgb){
|
||||
const uint8_t* r;uint32_t off;if(!v||!rgb||index>=v->palette_count)return SFA_BAD_ARGUMENT;
|
||||
r=v->data+v->records_offset+(size_t)index*20u;off=sfa_u32(r+8);
|
||||
if(!sfa_range(v->size,off,768))return SFA_CORRUPT;*rgb=v->data+off;return SFA_OK;
|
||||
}
|
||||
|
||||
static sfa_result sfp_colormap(const sfp_view* v,uint32_t index,const uint8_t** map){
|
||||
const uint8_t* r;uint32_t off;if(!v||!map||index>=v->colormap_count)return SFA_BAD_ARGUMENT;
|
||||
r=v->data+v->colormap_records_offset+(size_t)index*20u;off=sfa_u32(r+8);
|
||||
if(!sfa_range(v->size,off,256))return SFA_CORRUPT;*map=v->data+off;return SFA_OK;
|
||||
}
|
||||
|
||||
static sfa_result sfa_blit_lit(const sfa_view* v,const sfa_frame* f,const uint8_t rgb[768],const uint8_t* map,
|
||||
sfa_surface dst,int ax,int ay,sfa_blend_mode mode,sfa_light light){
|
||||
int sy,dy,x0=ax-f->pivot_x,y0=ay-f->pivot_y;const uint8_t *cb,*nb;float ll;
|
||||
if(!v||!f||!rgb||!dst.pixels||dst.stride<dst.width||!f->offset[0]||!f->offset[1])return SFA_BAD_ARGUMENT;
|
||||
cb=v->data+f->offset[0];nb=v->data+f->offset[1];
|
||||
if(f->size[0]<4u*((uint32_t)f->height+1u)||f->size[1]<4u*((uint32_t)f->height+1u))return SFA_CORRUPT;
|
||||
ll=sqrtf(light.x*light.x+light.y*light.y+light.z*light.z);if(ll>0){light.x/=ll;light.y/=ll;light.z/=ll;}
|
||||
for(sy=0;sy<(int)f->height;sy++){const uint8_t *cp,*ce,*np,*ne;uint32_t ca,cz,na,nz;uint16_t count,ncount,i;
|
||||
dy=y0+sy;if(dy<0||dy>=dst.height)continue;
|
||||
ca=sfa_u32(cb+sy*4);cz=sfa_u32(cb+(sy+1)*4);na=sfa_u32(nb+sy*4);nz=sfa_u32(nb+(sy+1)*4);
|
||||
if(ca>cz||cz>f->size[0]||na>nz||nz>f->size[1]||cz-ca<2||nz-na<2)return SFA_CORRUPT;
|
||||
cp=cb+ca;ce=cb+cz;np=nb+na;ne=nb+nz;count=sfa_u16(cp);ncount=sfa_u16(np);cp+=2;np+=2;if(count!=ncount)return SFA_CORRUPT;
|
||||
for(i=0;i<count;i++){uint16_t x,n,nx,nn,j;if((size_t)(ce-cp)<4||(size_t)(ne-np)<4)return SFA_CORRUPT;
|
||||
x=sfa_u16(cp);n=sfa_u16(cp+2);nx=sfa_u16(np);nn=sfa_u16(np+2);cp+=4;np+=4;
|
||||
if(x!=nx||n!=nn||(size_t)(ce-cp)<n||(size_t)(ne-np)<(size_t)n*2)return SFA_CORRUPT;
|
||||
for(j=0;j<n;j++){int dx=x0+x+j;if(dx>=0&&dx<dst.width){uint8_t k=map?map[cp[j]]:cp[j];float vx=(float)(int8_t)np[j*2]/127.0f,vy=(float)(int8_t)np[j*2+1]/127.0f;float vz=sqrtf(fmaxf(0.0f,1.0f-vx*vx-vy*vy));float d=vx*light.x+vy*light.y+vz*light.z;unsigned q=(unsigned)light.ambient+(unsigned)(d>0?d*(255-light.ambient):0);unsigned r=rgb[k*3]*q/255,g=rgb[k*3+1]*q/255,b=rgb[k*3+2]*q/255;uint32_t c=r|(g<<8)|(b<<16);uint32_t* target=dst.pixels+(size_t)dy*dst.stride+dx;*target=sfa_mix(*target,c,mode);}}
|
||||
cp+=n;np+=(size_t)n*2;}
|
||||
}return SFA_OK;
|
||||
}
|
||||
|
||||
static sfa_result sfa_load_file(const char* path,sfa_owned* o){FILE* f;long n;size_t got;sfa_result r;if(!path||!o)return SFA_BAD_ARGUMENT;memset(o,0,sizeof(*o));f=fopen(path,"rb");if(!f)return SFA_IO_ERROR;
|
||||
if(fseek(f,0,SEEK_END)|| (n=ftell(f))<0 || fseek(f,0,SEEK_SET)){fclose(f);return SFA_IO_ERROR;}o->data=(uint8_t*)malloc((size_t)n);if(!o->data){fclose(f);return SFA_NO_MEMORY;}got=fread(o->data,1,(size_t)n,f);fclose(f);if(got!=(size_t)n){free(o->data);memset(o,0,sizeof(*o));return SFA_IO_ERROR;}o->size=(size_t)n;r=sfa_open(o->data,o->size,&o->view);if(r!=SFA_OK){free(o->data);memset(o,0,sizeof(*o));}return r;}
|
||||
static void sfa_free_file(sfa_owned* o){if(o){free(o->data);memset(o,0,sizeof(*o));}}
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
17
include/sfa.hpp
Normal file
17
include/sfa.hpp
Normal file
@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
#include "sfa.h"
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace spriteforge {
|
||||
class File {
|
||||
sfa_owned owned_{};
|
||||
public:
|
||||
explicit File(const std::string& path) { if (sfa_load_file(path.c_str(), &owned_) != SFA_OK) throw std::runtime_error("failed to load SFA: "+path); }
|
||||
~File() { sfa_free_file(&owned_); }
|
||||
File(const File&) = delete; File& operator=(const File&) = delete;
|
||||
File(File&& other) noexcept : owned_(other.owned_) { other.owned_={}; }
|
||||
const sfa_view& view() const noexcept { return owned_.view; }
|
||||
sfa_frame frame(uint32_t i) const { sfa_frame f{}; if(sfa_get_frame(&owned_.view,i,&f)!=SFA_OK) throw std::out_of_range("SFA frame"); return f; }
|
||||
};
|
||||
}
|
||||
25
pyproject.toml
Normal file
25
pyproject.toml
Normal file
@ -0,0 +1,25 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "spriteforge"
|
||||
version = "0.1.0"
|
||||
description = "Standalone deterministic 2D sprite asset pipeline"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["numpy>=1.26", "Pillow>=10", "pydantic>=2", "PyYAML>=6", "typer>=0.12"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = ["pytest>=8"]
|
||||
|
||||
[project.scripts]
|
||||
sf = "spriteforge.cli:app"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
"spriteforge.studio" = ["web/*.html"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
3
src/spriteforge/__init__.py
Normal file
3
src/spriteforge/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""SpriteForge asset pipeline."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
44
src/spriteforge/backend_registry.py
Normal file
44
src/spriteforge/backend_registry.py
Normal file
@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
ContractValidator = Callable[[Any], str | None]
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackendContract:
|
||||
name: str
|
||||
validate: ContractValidator
|
||||
|
||||
_contracts: dict[str, BackendContract] = {}
|
||||
|
||||
def register_backend_contract(name: str, validator: ContractValidator, *, replace: bool = False) -> None:
|
||||
if not name or (name in _contracts and not replace):
|
||||
raise ValueError(f"backend contract already registered or invalid: {name}")
|
||||
_contracts[name] = BackendContract(name, validator)
|
||||
|
||||
def get_backend_contract(name: str) -> BackendContract | None:
|
||||
return _contracts.get(name)
|
||||
|
||||
def backend_names() -> tuple[str, ...]:
|
||||
return tuple(sorted(_contracts))
|
||||
|
||||
def _fields(required: str, forbidden: tuple[str, ...]) -> ContractValidator:
|
||||
def validate(spec: Any) -> str | None:
|
||||
if not getattr(spec, required):
|
||||
return f"backend '{spec.backend}' requires '{required}'"
|
||||
if any(getattr(spec, field) for field in forbidden):
|
||||
return f"fields from another backend are not allowed for '{spec.backend}'"
|
||||
return None
|
||||
return validate
|
||||
|
||||
register_backend_contract("procedural", _fields("generator", ("rig", "prompt", "source", "layers")))
|
||||
def _blender(spec:Any)->str|None:
|
||||
base=_fields("rig",("generator","prompt","source","layers"))(spec)
|
||||
if base:return base
|
||||
if not spec.parts:return "backend 'blender' requires non-empty 'parts'"
|
||||
if not spec.anims:return "backend 'blender' requires non-empty 'anims'"
|
||||
return None
|
||||
|
||||
register_backend_contract("blender",_blender)
|
||||
register_backend_contract("import", _fields("source", ("generator", "rig", "prompt", "layers")))
|
||||
register_backend_contract("composite", _fields("layers", ("generator", "rig", "prompt", "source")))
|
||||
8
src/spriteforge/backends/__init__.py
Normal file
8
src/spriteforge/backends/__init__.py
Normal file
@ -0,0 +1,8 @@
|
||||
from .base import Backend, BackendContext, RawAsset, RawFrame
|
||||
from .registry import create_backend, implemented_backends, register_backend
|
||||
from . import procedural as _procedural
|
||||
from . import blender as _blender
|
||||
from . import import_backend as _import
|
||||
from . import composite as _composite
|
||||
|
||||
__all__ = ["Backend", "BackendContext", "RawAsset", "RawFrame", "create_backend", "implemented_backends", "register_backend"]
|
||||
64
src/spriteforge/backends/base.py
Normal file
64
src/spriteforge/backends/base.py
Normal file
@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from spriteforge.manifest import AssetSpec
|
||||
|
||||
RgbaArray = NDArray[np.uint8]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackendContext:
|
||||
manifest_dir: Path
|
||||
asset_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RawFrame:
|
||||
rgba: RgbaArray
|
||||
pivot_x: int
|
||||
pivot_y: int
|
||||
duration_ms: int
|
||||
depth_min: float = 0.0
|
||||
depth_max: float = 1.0
|
||||
normals_xy: NDArray[np.uint8] | None = None
|
||||
depth: NDArray[np.uint8] | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.rgba.dtype != np.uint8 or self.rgba.ndim != 3 or self.rgba.shape[2] != 4:
|
||||
raise ValueError("backend frame must be an HxWx4 uint8 RGBA array")
|
||||
if self.normals_xy is not None and (self.normals_xy.dtype!=np.uint8 or self.normals_xy.shape!=self.rgba.shape[:2]+(2,)):
|
||||
raise ValueError("backend normals must be an HxWx2 uint8 array")
|
||||
if self.depth is not None and (self.depth.dtype!=np.uint8 or self.depth.shape!=self.rgba.shape[:2]):
|
||||
raise ValueError("backend depth must be an HxW uint8 array")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RawAsset:
|
||||
frames: tuple[RawFrame, ...]
|
||||
directions_centidegrees: tuple[int, ...] = (0,)
|
||||
animation: str = "default"
|
||||
layer_count: int = 1
|
||||
animations: tuple[tuple[str,int],...] = ()
|
||||
layer_orders: tuple[tuple[int,...],...] = ()
|
||||
warnings: tuple[str,...] = ()
|
||||
|
||||
|
||||
class Backend(ABC):
|
||||
name: str
|
||||
version: str
|
||||
|
||||
def dependencies(self, spec: "AssetSpec", context: BackendContext) -> tuple[Path,...]:
|
||||
"""Return source files whose bytes affect generation."""
|
||||
return ()
|
||||
|
||||
@abstractmethod
|
||||
def generate(self, spec: "AssetSpec", context: BackendContext) -> RawAsset:
|
||||
"""Generate unprocessed RGBA frames without writing files."""
|
||||
153
src/spriteforge/backends/blender.py
Normal file
153
src/spriteforge/backends/blender.py
Normal file
@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .base import Backend,BackendContext,RawAsset,RawFrame
|
||||
from .layering import analyze_depth_order,composite_layers
|
||||
from .registry import register_backend
|
||||
|
||||
ALLOWED_PARAMS={"executable","render_size","samples","ortho_scale","elevation","camera_distance","engine",
|
||||
"timeout_seconds","depth_epsilon","crossing_ratio","dither","actions","frames",
|
||||
"direction_offset_deg","camera_target","model_rotation_deg","loop_sampling","animation_fps","part_members","part_prefixes"}
|
||||
|
||||
def resolve_executable(configured:object=None)->str:
|
||||
"""Resolve Blender without making every project hardcode an install path.
|
||||
|
||||
An absolute/relative path remains authoritative. Command names use PATH
|
||||
first. On Windows the official installer commonly does not add Blender to
|
||||
PATH, so search its versioned installation directories as a final fallback.
|
||||
"""
|
||||
candidate=str(configured or os.environ.get("BLENDER_BIN") or "blender")
|
||||
path=Path(candidate).expanduser()
|
||||
if (path.is_absolute() or path.parent!=Path(".")) and path.is_file():
|
||||
return str(path.resolve())
|
||||
discovered=shutil.which(candidate)
|
||||
if discovered:return discovered
|
||||
if os.name=="nt" and candidate.lower() in {"blender","blender.exe"}:
|
||||
roots=[]
|
||||
for variable in ("ProgramFiles","ProgramW6432"):
|
||||
value=os.environ.get(variable)
|
||||
if value:roots.append(Path(value)/"Blender Foundation")
|
||||
matches=[]
|
||||
for root in dict.fromkeys(roots):
|
||||
if root.is_dir():matches.extend(root.glob("Blender */blender.exe"))
|
||||
if matches:return str(sorted(matches,key=lambda item:item.parent.name)[-1].resolve())
|
||||
raise ValueError(f"Blender executable not found: {candidate}; set BLENDER_BIN or params.executable")
|
||||
|
||||
def _pivot(value:str,width:int,height:int)->tuple[int,int]:
|
||||
known={"bottom_center":(width//2,height-1),"center":(width//2,height//2),
|
||||
"bottom_left":(0,height-1),"top_left":(0,0)}
|
||||
if value in known:return known[value]
|
||||
x,y=value.split(",");return int(x),int(y)
|
||||
|
||||
class BlenderBackend(Backend):
|
||||
name="blender"
|
||||
version="1.0.0"
|
||||
|
||||
def _rig(self,spec,context:BackendContext)->Path:
|
||||
path=Path(spec.rig);return (context.manifest_dir/path).resolve() if not path.is_absolute() else path.resolve()
|
||||
|
||||
def dependencies(self,spec,context:BackendContext)->tuple[Path,...]:
|
||||
return (self._rig(spec,context),)
|
||||
|
||||
def generate(self,spec,context:BackendContext)->RawAsset:
|
||||
unknown=set(spec.params)-ALLOWED_PARAMS
|
||||
if unknown:raise ValueError(f"unknown blender params: {', '.join(sorted(unknown))}")
|
||||
if not spec.parts:raise ValueError("blender backend requires non-empty parts mapping")
|
||||
if not spec.anims:raise ValueError("blender backend requires non-empty anims")
|
||||
rig=self._rig(spec,context)
|
||||
if not rig.is_file():raise ValueError(f"Blender rig does not exist: {rig}")
|
||||
executable=resolve_executable(spec.params.get("executable"))
|
||||
render_size=spec.params.get("render_size") or spec.size or "256x256"
|
||||
if not isinstance(render_size,str) or "x" not in render_size:raise ValueError("params.render_size must be WIDTHxHEIGHT")
|
||||
width,height=(int(value) for value in render_size.split("x",1))
|
||||
actions=spec.params.get("actions",{})
|
||||
frames=spec.params.get("frames",{})
|
||||
animation_fps=spec.params.get("animation_fps",{})
|
||||
members=spec.params.get("part_members",{})
|
||||
prefixes=spec.params.get("part_prefixes",{})
|
||||
if not isinstance(actions,dict) or not all(isinstance(k,str) and isinstance(v,str) for k,v in actions.items()):
|
||||
raise ValueError("params.actions must map manifest clip names to Blender action names")
|
||||
if not isinstance(frames,dict) or not all(isinstance(k,str) and isinstance(v,int) and v>0 for k,v in frames.items()):
|
||||
raise ValueError("params.frames must map clip names to positive frame counts")
|
||||
if not isinstance(animation_fps,dict) or not all(isinstance(k,str) and isinstance(v,(int,float)) and not isinstance(v,bool) and v>0 for k,v in animation_fps.items()):
|
||||
raise ValueError("params.animation_fps must map clip names to positive frame rates")
|
||||
loop_sampling=spec.params.get("loop_sampling",True)
|
||||
if not isinstance(loop_sampling,(bool,dict)) or isinstance(loop_sampling,dict) and not all(isinstance(k,str) and isinstance(v,bool) for k,v in loop_sampling.items()):
|
||||
raise ValueError("params.loop_sampling must be a bool or map clip names to bools")
|
||||
if not isinstance(members,dict) or not all(isinstance(k,str) and isinstance(v,list) and v and all(isinstance(n,str) for n in v) for k,v in members.items()):
|
||||
raise ValueError("params.part_members must map layer names to non-empty object-name lists")
|
||||
if not isinstance(prefixes,dict) or not all(isinstance(k,str) and isinstance(v,str) and v for k,v in prefixes.items()):
|
||||
raise ValueError("params.part_prefixes must map layer names to non-empty object-name prefixes")
|
||||
if set(prefixes)-set(spec.parts):raise ValueError("params.part_prefixes keys must name declared parts")
|
||||
unknown_clip_keys=(set(actions)|set(frames)|set(animation_fps)|(set(loop_sampling) if isinstance(loop_sampling,dict) else set()))-set(spec.anims)
|
||||
if unknown_clip_keys:raise ValueError("actions/frames/animation_fps/loop_sampling keys must name declared anims")
|
||||
request={"version":1,"asset_id":context.asset_id,"parts":spec.parts,"animations":spec.anims,"dirs":spec.dirs,
|
||||
"fps":spec.fps,"width":width,"height":height,"samples":int(spec.params.get("samples",16)),
|
||||
"ortho_scale":float(spec.params.get("ortho_scale",4.0)),"elevation":float(spec.params.get("elevation",35.264)),
|
||||
"camera_distance":float(spec.params.get("camera_distance",10.0)),"engine":spec.params.get("engine","BLENDER_EEVEE_NEXT"),
|
||||
"actions":actions,"frames":frames,"animation_fps":animation_fps,"part_members":members,"part_prefixes":prefixes,
|
||||
"direction_offset_deg":float(spec.params.get("direction_offset_deg",0.0)),
|
||||
"camera_target":spec.params.get("camera_target",[0.0,0.0,0.0]),
|
||||
"model_rotation_deg":float(spec.params.get("model_rotation_deg",0.0)),
|
||||
"loop_sampling":loop_sampling}
|
||||
driver=Path(__file__).with_name("blender_driver.py")
|
||||
with tempfile.TemporaryDirectory(prefix="spriteforge-blender-") as temporary:
|
||||
root=Path(temporary);request_path=root/"request.json";result_path=root/"result.json";output=root/"frames"
|
||||
request_path.write_text(json.dumps(request),encoding="utf-8")
|
||||
command=[executable,"-b",str(rig),"-P",str(driver),"--","--request",str(request_path),"--output",str(output),"--result",str(result_path)]
|
||||
timeout=float(spec.params.get("timeout_seconds",3600))
|
||||
try:completed=subprocess.run(command,cwd=context.manifest_dir,capture_output=True,text=True,timeout=timeout,check=False)
|
||||
except (OSError,subprocess.TimeoutExpired) as error:raise ValueError(f"Blender launch failed: {error}") from error
|
||||
if completed.returncode!=0:raise ValueError(f"Blender exited with {completed.returncode}: {(completed.stderr or completed.stdout)[-4000:]}")
|
||||
if not result_path.is_file():
|
||||
log=(completed.stderr or completed.stdout or "<no Blender output>")[-4000:]
|
||||
raise ValueError(f"Blender driver produced no result.json: {log}")
|
||||
result=json.loads(result_path.read_text(encoding="utf-8"))
|
||||
return self._consume(result,output,spec,context)
|
||||
|
||||
def _consume(self,result:dict[str,Any],output:Path,spec,context:BackendContext)->RawAsset:
|
||||
if result.get("version")!=1:raise ValueError("unsupported Blender driver result")
|
||||
names=list(spec.parts);directions=tuple(int(x) for x in result.get("directions_centidegrees",()))
|
||||
if len(directions)!=spec.dirs:raise ValueError("Blender result direction count mismatch")
|
||||
animations=tuple((item["name"],int(item["frames_per_direction"])) for item in result.get("animations",()))
|
||||
if tuple(name for name,_ in animations)!=tuple(spec.anims):raise ValueError("Blender result animation list mismatch")
|
||||
expected=sum(count*len(directions) for _,count in animations);items=result.get("frames",())
|
||||
if len(items)!=expected:raise ValueError("Blender result frame count mismatch")
|
||||
frames=[];orders=[];warnings=[]
|
||||
epsilon=float(spec.params.get("depth_epsilon",.001));ratio=float(spec.params.get("crossing_ratio",.05))
|
||||
for number,item in enumerate(items):
|
||||
parts=item.get("parts",())
|
||||
if [part.get("name") for part in parts]!=names:raise ValueError(f"Blender frame {number} part list mismatch")
|
||||
rgba=[];depth=[];normals=[]
|
||||
for part in parts:
|
||||
rgba.append(self._array(output,part["rgba"],np.uint8,3,4))
|
||||
depth.append(self._array(output,part["depth"],np.float32,2,None))
|
||||
normals.append(self._array(output,part["normal"],np.uint8,3,2))
|
||||
context_text=f"asset={context.asset_id} animation={item.get('animation')} direction={item.get('direction_index')} frame={item.get('frame_index')}"
|
||||
analysis=analyze_depth_order(names,rgba,depth,epsilon=epsilon,crossing_ratio=ratio,context=context_text)
|
||||
composed,normal,encoded,dmin,dmax=composite_layers(analysis.order,rgba,depth,normals)
|
||||
px,py=_pivot(spec.pivot,composed.shape[1],composed.shape[0])
|
||||
frames.append(RawFrame(composed,px,py,int(item["duration_ms"]),dmin,dmax,normal,encoded))
|
||||
orders.append(analysis.order);warnings.extend(analysis.warnings)
|
||||
return RawAsset(tuple(frames),directions,animations[0][0],len(names),animations,tuple(orders),tuple(warnings))
|
||||
|
||||
@staticmethod
|
||||
def _array(output:Path,name:str,dtype,dimensions:int,channels:int|None)->np.ndarray:
|
||||
path=(output/name).resolve()
|
||||
if output.resolve() not in path.parents:raise ValueError("unsafe Blender result path")
|
||||
try:value=np.load(path,allow_pickle=False)
|
||||
except (OSError,ValueError) as error:raise ValueError(f"cannot read Blender result {name}: {error}") from error
|
||||
if value.dtype!=dtype or value.ndim!=dimensions or (channels is not None and value.shape[-1]!=channels):
|
||||
raise ValueError(f"invalid Blender array {name}")
|
||||
return np.ascontiguousarray(value)
|
||||
|
||||
register_backend("blender",BlenderBackend)
|
||||
156
src/spriteforge/backends/blender_driver.py
Normal file
156
src/spriteforge/backends/blender_driver.py
Normal file
@ -0,0 +1,156 @@
|
||||
"""Executed inside Blender; communicates only through JSON and NPY files."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
|
||||
def configure_modern_pass_output(scene,output:Path):
|
||||
"""Blender 5 removed Image.layers; export passes through the compositor."""
|
||||
if bpy.app.version < (5,0,0):return None
|
||||
try:
|
||||
import OpenImageIO # bundled with official Blender distributions
|
||||
except ImportError as error:
|
||||
raise RuntimeError("Blender 5 pass extraction requires bundled OpenImageIO") from error
|
||||
scene.use_nodes=True
|
||||
tree=scene.compositing_node_group
|
||||
if tree is None:
|
||||
tree=bpy.data.node_groups.new("SpriteForge compositor","CompositorNodeTree")
|
||||
scene.compositing_node_group=tree
|
||||
render_node=tree.nodes.new("CompositorNodeRLayers")
|
||||
scene.view_layers[0].update_render_passes()
|
||||
file_node=tree.nodes.new("CompositorNodeOutputFile")
|
||||
file_node.directory=str(output.resolve());file_node.format.file_format="OPEN_EXR_MULTILAYER"
|
||||
file_node.file_output_items.clear()
|
||||
for socket_type,name in (("RGBA","Image"),("FLOAT","Depth"),("VECTOR","Normal")):
|
||||
file_node.file_output_items.new(socket_type,name)
|
||||
tree.links.new(render_node.outputs[name],file_node.inputs[name])
|
||||
return file_node
|
||||
|
||||
def read_modern_passes(path:Path):
|
||||
import OpenImageIO
|
||||
source=OpenImageIO.ImageInput.open(str(path))
|
||||
if source is None:raise RuntimeError(f"cannot open Blender pass EXR: {path}: {OpenImageIO.geterror()}")
|
||||
images={}
|
||||
index=0
|
||||
try:
|
||||
while source.seek_subimage(index,0):
|
||||
spec=source.spec();names=list(spec.channelnames)
|
||||
pixels=np.asarray(source.read_image(format=OpenImageIO.FLOAT),dtype=np.float32)
|
||||
if names and names[0].startswith("Depth."):images["depth"]=pixels[...,0]
|
||||
elif names and names[0].startswith("Normal."):images["normal"]=pixels[...,:3]
|
||||
index+=1
|
||||
finally:source.close()
|
||||
if "depth" not in images or "normal" not in images:
|
||||
raise RuntimeError(f"Blender pass EXR lacks Depth/Normal subimages: {path}")
|
||||
return images["depth"],images["normal"]
|
||||
|
||||
def arguments():
|
||||
tail=sys.argv[sys.argv.index("--")+1:] if "--" in sys.argv else []
|
||||
parser=argparse.ArgumentParser();parser.add_argument("--request",required=True);parser.add_argument("--output",required=True);parser.add_argument("--result",required=True)
|
||||
return parser.parse_args(tail)
|
||||
|
||||
def look_at(camera,point=Vector((0,0,0))):
|
||||
camera.rotation_euler=(point-camera.location).to_track_quat("-Z","Y").to_euler()
|
||||
|
||||
def render_pass(scene,output:Path,stem:str,modern_output=None):
|
||||
if modern_output is not None:modern_output.file_name=f"{stem}_passes"
|
||||
png=output/f"{stem}.png";scene.render.filepath=str(png);bpy.ops.render.render(write_still=True)
|
||||
image=bpy.data.images.load(str(png),check_existing=False);w,h=image.size
|
||||
rgba=np.asarray(image.pixels[:],dtype=np.float32).reshape(h,w,4);rgba=np.flipud(rgba)
|
||||
bpy.data.images.remove(image)
|
||||
if modern_output is not None:
|
||||
z,normal=read_modern_passes(output/f"{stem}_passes.exr")
|
||||
else:
|
||||
result=bpy.data.images.get("Render Result");layer=result.layers[0]
|
||||
z=np.asarray(layer.passes["Depth"].rect[:],dtype=np.float32).reshape(h,w,4)[...,0];z=np.flipud(z)
|
||||
normal=np.asarray(layer.passes["Normal"].rect[:],dtype=np.float32).reshape(h,w,4)[...,:3];normal=np.flipud(normal)
|
||||
encoded=np.clip(np.rint(normal[...,:2]*127),-127,127).astype(np.int8).view(np.uint8)
|
||||
rgba8=np.clip(np.rint(rgba*255),0,255).astype(np.uint8);mask=rgba8[...,3]!=0
|
||||
z=np.where(mask,z,np.inf).astype(np.float32);encoded[~mask]=0
|
||||
rp=f"{stem}_rgba.npy";dp=f"{stem}_depth.npy";np_=f"{stem}_normal.npy"
|
||||
np.save(output/rp,rgba8,allow_pickle=False);np.save(output/dp,z,allow_pickle=False);np.save(output/np_,encoded,allow_pickle=False)
|
||||
return {"rgba":rp,"depth":dp,"normal":np_}
|
||||
|
||||
def main():
|
||||
args=arguments();request=json.loads(Path(args.request).read_text());output=Path(args.output);output.mkdir(parents=True,exist_ok=True)
|
||||
scene=bpy.context.scene;scene.render.resolution_x=int(request["width"]);scene.render.resolution_y=int(request["height"]);scene.render.resolution_percentage=100
|
||||
scene.render.film_transparent=True;scene.render.image_settings.file_format="PNG";scene.render.image_settings.color_mode="RGBA"
|
||||
scene.render.engine=request["engine"]
|
||||
if hasattr(scene,"eevee"):scene.eevee.taa_render_samples=int(request["samples"])
|
||||
scene.view_layers[0].use_pass_z=True;scene.view_layers[0].use_pass_normal=True
|
||||
modern_output=configure_modern_pass_output(scene,output)
|
||||
camera_data=bpy.data.cameras.new("SpriteForgeCamera");camera=bpy.data.objects.new("SpriteForgeCamera",camera_data);scene.collection.objects.link(camera);scene.camera=camera
|
||||
camera_data.type="ORTHO";camera_data.ortho_scale=float(request["ortho_scale"])
|
||||
distance=float(request["camera_distance"]);elevation=math.radians(float(request["elevation"]));parts=request["parts"]
|
||||
target=Vector(tuple(float(value) for value in request.get("camera_target",(0,0,0))))
|
||||
if len(target)!=3:raise RuntimeError("camera_target must contain three numbers")
|
||||
direction_offset=math.radians(float(request.get("direction_offset_deg",0.0)))
|
||||
model_rotation=math.radians(float(request.get("model_rotation_deg",0.0)))
|
||||
if model_rotation:
|
||||
roots=[obj for obj in scene.objects if obj.parent is None and obj.type not in {"CAMERA","LIGHT"}]
|
||||
for obj in roots:obj.rotation_euler.z+=model_rotation
|
||||
configured_members=request.get("part_members",{})
|
||||
configured_prefixes=request.get("part_prefixes",{})
|
||||
objects=[]
|
||||
for logical,object_name in parts.items():
|
||||
if logical in configured_members:names=configured_members[logical]
|
||||
elif logical in configured_prefixes:
|
||||
prefix=configured_prefixes[logical]
|
||||
names=sorted(obj.name for obj in scene.objects
|
||||
if obj.type in {"MESH","CURVE","SURFACE","META","FONT"} and obj.name.startswith(prefix))
|
||||
if not names:raise RuntimeError(f"part prefix matched no renderable objects: {logical}={prefix}")
|
||||
else:names=[object_name]
|
||||
layer=[]
|
||||
for name in names:
|
||||
obj=bpy.data.objects.get(name)
|
||||
if obj is None:raise RuntimeError(f"part object not found: {logical}={name}")
|
||||
if obj.type not in {"MESH","CURVE","SURFACE","META","FONT"}:raise RuntimeError(f"part object is not directly renderable: {logical}={name}")
|
||||
layer.append(obj)
|
||||
objects.append((logical,layer))
|
||||
armatures=[obj for obj in scene.objects if obj.type=="ARMATURE"]
|
||||
original_visibility={obj.name:obj.hide_render for obj in scene.objects}
|
||||
metadata={"version":1,"directions_centidegrees":[round(i*36000/request["dirs"]) for i in range(request["dirs"])],"animations":[],"frames":[]}
|
||||
try:
|
||||
for animation_name in request["animations"]:
|
||||
action_name=request.get("actions",{}).get(animation_name,animation_name)
|
||||
action=bpy.data.actions.get(action_name)
|
||||
if action is None:raise RuntimeError(f"action not found: {animation_name} -> {action_name}")
|
||||
start,end=action.frame_range;source_fps=scene.render.fps/scene.render.fps_base
|
||||
clip_fps=float(request.get("animation_fps",{}).get(animation_name,request["fps"]))
|
||||
frame_count=int(request.get("frames",{}).get(animation_name,max(1,round(max(1.0,end-start+1)*clip_fps/source_fps))))
|
||||
metadata["animations"].append({"name":animation_name,"frames_per_direction":frame_count})
|
||||
for armature in armatures:
|
||||
if armature.animation_data is None:armature.animation_data_create()
|
||||
armature.animation_data.action=action
|
||||
for direction in range(request["dirs"]):
|
||||
azimuth=direction_offset+2*math.pi*direction/request["dirs"]
|
||||
horizontal=distance*math.cos(elevation)
|
||||
camera.location=target+Vector((horizontal*math.cos(azimuth),horizontal*math.sin(azimuth),distance*math.sin(elevation)));look_at(camera,target)
|
||||
for frame_index in range(frame_count):
|
||||
loop_setting=request.get("loop_sampling",True)
|
||||
loop_clip=loop_setting.get(animation_name,True) if isinstance(loop_setting,dict) else loop_setting
|
||||
denominator=(frame_count if loop_clip else max(1,frame_count-1))
|
||||
source_frame=start if frame_count==1 else start+(end-start)*frame_index/denominator
|
||||
scene.frame_set(int(math.floor(source_frame)),subframe=source_frame-math.floor(source_frame))
|
||||
record={"animation":animation_name,"direction_index":direction,"frame_index":frame_index,
|
||||
"duration_ms":max(1,round(1000/clip_fps)),"parts":[]}
|
||||
for part_index,(logical,layer_objects) in enumerate(objects):
|
||||
for candidate in scene.objects:
|
||||
if candidate.type in {"MESH","CURVE","SURFACE","META","FONT"}:candidate.hide_render=candidate not in layer_objects
|
||||
stem=f"a{len(metadata['animations'])-1:03d}_d{direction:03d}_f{frame_index:04d}_p{part_index:03d}"
|
||||
files=render_pass(scene,output,stem,modern_output);record["parts"].append({"name":logical,**files})
|
||||
metadata["frames"].append(record)
|
||||
finally:
|
||||
for name,value in original_visibility.items():
|
||||
obj=bpy.data.objects.get(name)
|
||||
if obj is not None:obj.hide_render=value
|
||||
Path(args.result).write_text(json.dumps(metadata,sort_keys=True),encoding="utf-8")
|
||||
|
||||
if __name__=="__main__":main()
|
||||
48
src/spriteforge/backends/composite.py
Normal file
48
src/spriteforge/backends/composite.py
Normal file
@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from .base import Backend, BackendContext, RawAsset, RawFrame
|
||||
from .import_backend import _pivot
|
||||
from .registry import register_backend
|
||||
|
||||
|
||||
def _layer_path(value: str, context: BackendContext) -> Path:
|
||||
return (context.manifest_dir / value).resolve()
|
||||
|
||||
|
||||
def _blend(bottom: np.ndarray, top: np.ndarray) -> None:
|
||||
alpha = top[..., 3:4].astype(np.uint16)
|
||||
inverse = 255 - alpha
|
||||
bottom[..., :3] = ((top[..., :3].astype(np.uint16)*alpha + bottom[..., :3].astype(np.uint16)*inverse + 127)//255).astype(np.uint8)
|
||||
bottom[..., 3:4] = (alpha + (bottom[..., 3:4].astype(np.uint16)*inverse + 127)//255).astype(np.uint8)
|
||||
|
||||
|
||||
class CompositeBackend(Backend):
|
||||
"""Composite equally-sized PNG layers in manifest order (back to front)."""
|
||||
name = "composite"; version = "1.0.0"
|
||||
|
||||
def dependencies(self, spec, context: BackendContext) -> tuple[Path, ...]:
|
||||
return tuple(_layer_path(layer, context) for layer in spec.layers)
|
||||
|
||||
def generate(self, spec, context: BackendContext) -> RawAsset:
|
||||
allowed = {"dither"}; unknown = set(spec.params) - allowed
|
||||
if unknown: raise ValueError(f"unknown composite params: {', '.join(sorted(unknown))}")
|
||||
images = []
|
||||
for layer in spec.layers:
|
||||
path = _layer_path(layer, context)
|
||||
try: images.append(np.asarray(Image.open(path).convert("RGBA"), dtype=np.uint8))
|
||||
except (OSError, ValueError) as error: raise ValueError(f"cannot load composite layer {path}: {error}") from error
|
||||
shape = images[0].shape
|
||||
if any(image.shape != shape for image in images): raise ValueError("all composite layers must have equal dimensions")
|
||||
result = np.zeros(shape, dtype=np.uint8)
|
||||
for image in images: _blend(result, image)
|
||||
pivot = _pivot(spec, shape[1], shape[0])
|
||||
frame = RawFrame(result, *pivot, max(1, round(1000/spec.fps)))
|
||||
return RawAsset((frame,), (0,), "default", len(images), (("default", 1),), (tuple(range(len(images))),))
|
||||
|
||||
|
||||
register_backend("composite", CompositeBackend)
|
||||
68
src/spriteforge/backends/import_backend.py
Normal file
68
src/spriteforge/backends/import_backend.py
Normal file
@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from .base import Backend, BackendContext, RawAsset, RawFrame
|
||||
from .registry import register_backend
|
||||
|
||||
|
||||
def _path(value: str, context: BackendContext) -> Path:
|
||||
return (context.manifest_dir / value).resolve()
|
||||
|
||||
|
||||
def _integer(params: dict[str, Any], name: str, default: int, minimum: int = 1) -> int:
|
||||
value = params.get(name, default)
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < minimum:
|
||||
raise ValueError(f"params.{name} must be an integer >= {minimum}")
|
||||
return value
|
||||
|
||||
|
||||
def _pivot(spec, width: int, height: int) -> tuple[int, int]:
|
||||
if spec.pivot == "bottom_center": return width // 2, height - 1
|
||||
if spec.pivot == "center": return width // 2, height // 2
|
||||
if spec.pivot == "bottom_left": return 0, height - 1
|
||||
if spec.pivot == "top_left": return 0, 0
|
||||
x, y = spec.pivot.split(","); return int(x), int(y)
|
||||
|
||||
|
||||
def load_rgba_frames(path: Path, params: dict[str, Any]) -> tuple[np.ndarray, ...]:
|
||||
try:
|
||||
image = np.asarray(Image.open(path).convert("RGBA"), dtype=np.uint8)
|
||||
except (OSError, ValueError) as error:
|
||||
raise ValueError(f"cannot import image {path}: {error}") from error
|
||||
height, width = image.shape[:2]
|
||||
frame_width = _integer(params, "frame_width", width)
|
||||
frame_height = _integer(params, "frame_height", height)
|
||||
if width % frame_width or height % frame_height:
|
||||
raise ValueError("sprite sheet dimensions must be divisible by frame_width/frame_height")
|
||||
columns, rows = width // frame_width, height // frame_height
|
||||
count = _integer(params, "frames", columns * rows)
|
||||
if count > columns * rows: raise ValueError("params.frames exceeds sprite sheet capacity")
|
||||
return tuple(np.array(image[(i // columns)*frame_height:(i // columns+1)*frame_height,
|
||||
(i % columns)*frame_width:(i % columns+1)*frame_width], copy=True)
|
||||
for i in range(count))
|
||||
|
||||
|
||||
class ImportBackend(Backend):
|
||||
name = "import"; version = "1.0.0"
|
||||
|
||||
def dependencies(self, spec, context: BackendContext) -> tuple[Path, ...]:
|
||||
return (_path(spec.source, context),)
|
||||
|
||||
def generate(self, spec, context: BackendContext) -> RawAsset:
|
||||
allowed = {"frame_width", "frame_height", "frames", "dither"}
|
||||
unknown = set(spec.params) - allowed
|
||||
if unknown: raise ValueError(f"unknown import params: {', '.join(sorted(unknown))}")
|
||||
images = load_rgba_frames(_path(spec.source, context), spec.params)
|
||||
if len(images) % spec.dirs: raise ValueError("imported frame count must be divisible by dirs")
|
||||
frames = tuple(RawFrame(image, *_pivot(spec, image.shape[1], image.shape[0]), max(1, round(1000/spec.fps))) for image in images)
|
||||
per_direction = len(frames) // spec.dirs
|
||||
return RawAsset(frames, tuple(round(i * 36000 / spec.dirs) for i in range(spec.dirs)),
|
||||
"default", 1, (("default", per_direction),))
|
||||
|
||||
|
||||
register_backend("import", ImportBackend)
|
||||
62
src/spriteforge/backends/layering.py
Normal file
62
src/spriteforge/backends/layering.py
Normal file
@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import numpy as np
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LayerAnalysis:
|
||||
order:tuple[int,...]
|
||||
warnings:tuple[str,...]
|
||||
|
||||
def analyze_depth_order(names:list[str],rgba:list[np.ndarray],depth:list[np.ndarray],*,epsilon:float=.001,
|
||||
crossing_ratio:float=.05,context:str="")->LayerAnalysis:
|
||||
count=len(names)
|
||||
if not count or len(rgba)!=count or len(depth)!=count:raise ValueError("layer arrays must have equal nonzero length")
|
||||
shape=rgba[0].shape[:2]
|
||||
if any(image.shape[:2]!=shape for image in rgba) or any(item.shape!=shape for item in depth):raise ValueError("layer dimensions differ")
|
||||
edges={i:set() for i in range(count)};warnings=[]
|
||||
medians=[]
|
||||
for i in range(count):
|
||||
valid=(rgba[i][...,3]!=0)&np.isfinite(depth[i])
|
||||
medians.append(float(np.median(depth[i][valid])) if valid.any() else float("inf"))
|
||||
for a in range(count):
|
||||
for b in range(a+1,count):
|
||||
overlap=(rgba[a][...,3]!=0)&(rgba[b][...,3]!=0)&np.isfinite(depth[a])&np.isfinite(depth[b])
|
||||
if not overlap.any():continue
|
||||
delta=depth[a][overlap]-depth[b][overlap]
|
||||
a_front=int(np.count_nonzero(delta < -epsilon));b_front=int(np.count_nonzero(delta > epsilon));decided=a_front+b_front
|
||||
if not decided:continue
|
||||
if a_front and b_front and min(a_front,b_front)/decided>=crossing_ratio:
|
||||
prefix=f"{context}: " if context else ""
|
||||
warnings.append(f"{prefix}depth ranges overlap for layers '{names[a]}' and '{names[b]}' ({a_front}/{b_front} pixels); split into passes")
|
||||
if a_front>=b_front:edges[b].add(a)
|
||||
else:edges[a].add(b)
|
||||
incoming={i:0 for i in range(count)}
|
||||
for targets in edges.values():
|
||||
for target in targets:incoming[target]+=1
|
||||
ready=sorted((i for i in range(count) if incoming[i]==0),key=lambda i:(-medians[i],names[i]))
|
||||
order=[]
|
||||
while ready:
|
||||
node=ready.pop(0);order.append(node)
|
||||
for target in sorted(edges[node]):
|
||||
incoming[target]-=1
|
||||
if incoming[target]==0:
|
||||
ready.append(target);ready.sort(key=lambda i:(-medians[i],names[i]))
|
||||
if len(order)!=count:
|
||||
prefix=f"{context}: " if context else ""
|
||||
warnings.append(f"{prefix}cyclic layer ordering; using median-depth fallback")
|
||||
order=sorted(range(count),key=lambda i:(-medians[i],names[i]))
|
||||
return LayerAnalysis(tuple(order),tuple(warnings))
|
||||
|
||||
def composite_layers(order:tuple[int,...],rgba:list[np.ndarray],depth:list[np.ndarray],normals:list[np.ndarray])->tuple[np.ndarray,np.ndarray,np.ndarray,float,float]:
|
||||
h,w=rgba[0].shape[:2];out=np.zeros((h,w,4),dtype=np.uint8);world=np.full((h,w),np.inf,dtype=np.float32)
|
||||
normal=np.zeros((h,w,2),dtype=np.uint8)
|
||||
for index in order:
|
||||
source=rgba[index];mask=source[...,3]!=0
|
||||
out[mask]=source[mask];world[mask]=depth[index][mask];normal[mask]=normals[index][mask]
|
||||
valid=np.isfinite(world)&(out[...,3]!=0)
|
||||
if valid.any():
|
||||
minimum=float(world[valid].min());maximum=float(world[valid].max())
|
||||
if maximum>minimum:encoded=np.where(valid,np.rint((world-minimum)*255/(maximum-minimum)),0)
|
||||
else:encoded=np.where(valid,0,0)
|
||||
else:minimum=maximum=0.0;encoded=np.zeros((h,w))
|
||||
return out,normal,np.clip(encoded,0,255).astype(np.uint8),minimum,maximum
|
||||
169
src/spriteforge/backends/procedural.py
Normal file
169
src/spriteforge/backends/procedural.py
Normal file
@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .base import Backend, BackendContext, RawAsset, RawFrame, RgbaArray
|
||||
from .registry import register_backend
|
||||
from .sequence import validate_sequence_asset
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from spriteforge.manifest import AssetSpec
|
||||
|
||||
# Однокадровый генератор отдаёт картинку и пивот, всё остальное (варианты,
|
||||
# длительность, направление) достраивает бэкенд.
|
||||
Generator = Callable[[dict[str, Any], np.random.Generator], tuple[RgbaArray, tuple[int, int]]]
|
||||
# Генератор-последовательность сам решает, сколько у него анимаций, направлений
|
||||
# и кадров, поэтому получает весь spec и возвращает готовый RawAsset.
|
||||
SequenceGenerator = Callable[["AssetSpec", np.random.Generator], RawAsset]
|
||||
_generators: dict[str, Generator] = {}
|
||||
_sequence_generators: dict[str, SequenceGenerator] = {}
|
||||
|
||||
def _check_free(name: str, registry: dict[str, Any], other: dict[str, Any], replace: bool, kind: str) -> None:
|
||||
# Имя обязано быть свободно в обоих реестрах сразу: иначе выбор генератора
|
||||
# по имени из манифеста перестаёт быть однозначным.
|
||||
if not name or name in other or (name in registry and not replace):
|
||||
raise ValueError(f"procedural {kind} already registered or invalid: {name}")
|
||||
|
||||
def register_generator(name: str, generator: Generator, *, replace: bool = False) -> None:
|
||||
_check_free(name, _generators, _sequence_generators, replace, "generator")
|
||||
_generators[name] = generator
|
||||
|
||||
def register_sequence_generator(name: str, generator: SequenceGenerator, *, replace: bool = False) -> None:
|
||||
_check_free(name, _sequence_generators, _generators, replace, "sequence generator")
|
||||
_sequence_generators[name] = generator
|
||||
|
||||
def generator_names() -> tuple[str, ...]:
|
||||
return tuple(sorted(_generators))
|
||||
|
||||
def sequence_generator_names() -> tuple[str, ...]:
|
||||
return tuple(sorted(_sequence_generators))
|
||||
|
||||
def _color(params: dict[str, Any], key: str, default: str) -> np.ndarray:
|
||||
value = params.get(key, default)
|
||||
if not isinstance(value, str) or len(value) not in (7, 9) or not value.startswith("#"):
|
||||
raise ValueError(f"params.{key} must be #RRGGBB or #RRGGBBAA")
|
||||
try:
|
||||
channels = [int(value[i:i+2], 16) for i in range(1, len(value), 2)]
|
||||
except ValueError as error:
|
||||
raise ValueError(f"params.{key} contains invalid hex") from error
|
||||
if len(channels) == 3:
|
||||
channels.append(255)
|
||||
return np.asarray(channels, dtype=np.uint8)
|
||||
|
||||
def _positive_int(params: dict[str, Any], key: str, default: int, maximum: int = 4096) -> int:
|
||||
value = params.get(key, default)
|
||||
if not isinstance(value, int) or isinstance(value, bool) or not 1 <= value <= maximum:
|
||||
raise ValueError(f"params.{key} must be an integer in 1..{maximum}")
|
||||
return value
|
||||
|
||||
def _variant_seed(seed: int, variant: int) -> int:
|
||||
# Поток случайных чисел зависит только от seed ассета и номера варианта.
|
||||
# Шаг золотого сечения разводит соседние seed'ы, иначе варианты 0 и 1
|
||||
# получили бы почти одинаковый шум.
|
||||
return (int(seed) + variant * 0x9E3779B1) & 0xFFFFFFFFFFFFFFFF
|
||||
|
||||
def _reject_unknown(params:dict[str,Any],allowed:set[str])->None:
|
||||
unknown=set(params)-allowed
|
||||
if unknown:raise ValueError(f"unknown procedural params: {', '.join(sorted(unknown))}")
|
||||
|
||||
def _diamond_mask(width: int, height: int) -> np.ndarray:
|
||||
yy, xx = np.mgrid[0:height, 0:width]
|
||||
nx = np.abs((xx + 0.5 - width / 2) / (width / 2))
|
||||
ny = np.abs((yy + 0.5 - height / 2) / (height / 2))
|
||||
return nx + ny <= 1.0
|
||||
|
||||
def floor_tile(params: dict[str, Any], rng: np.random.Generator) -> tuple[RgbaArray, tuple[int, int]]:
|
||||
_reject_unknown(params,{"width","height","color","edge_color","noise","variants","dither"})
|
||||
width = _positive_int(params, "width", 64)
|
||||
height = _positive_int(params, "height", width // 2)
|
||||
base = _color(params, "color", "#707070")
|
||||
edge = _color(params, "edge_color", "#484848")
|
||||
noise = int(params.get("noise", 12))
|
||||
image = np.zeros((height, width, 4), dtype=np.uint8)
|
||||
mask = _diamond_mask(width, height)
|
||||
distance = np.abs((np.indices((height, width))[1] + .5 - width/2)/(width/2)) + np.abs((np.indices((height, width))[0] + .5-height/2)/(height/2))
|
||||
image[mask] = base
|
||||
image[mask & (distance > .88)] = edge
|
||||
if noise:
|
||||
jitter = rng.integers(-noise, noise + 1, size=(height, width, 1), dtype=np.int16)
|
||||
rgb = image[..., :3].astype(np.int16)
|
||||
image[..., :3] = np.where(mask[..., None], np.clip(rgb + jitter, 0, 255), rgb).astype(np.uint8)
|
||||
return image, (width // 2, height // 2)
|
||||
|
||||
def wall_tile(params: dict[str, Any], rng: np.random.Generator) -> tuple[RgbaArray, tuple[int, int]]:
|
||||
_reject_unknown(params,{"width","top_height","height","top_color","left_color","right_color","variants","dither"})
|
||||
width = _positive_int(params, "width", 64)
|
||||
top_height = _positive_int(params, "top_height", width // 2)
|
||||
wall_height = _positive_int(params, "height", 64)
|
||||
top = _color(params, "top_color", "#808080")
|
||||
left = _color(params, "left_color", "#505050")
|
||||
right = _color(params, "right_color", "#686868")
|
||||
image = np.zeros((top_height + wall_height, width, 4), dtype=np.uint8)
|
||||
mask = _diamond_mask(width, top_height)
|
||||
image[:top_height][mask] = top
|
||||
for x in range(width):
|
||||
ys = np.flatnonzero(mask[:, x])
|
||||
if not len(ys):
|
||||
continue
|
||||
bottom = int(ys[-1])
|
||||
color = left if x < width // 2 else right
|
||||
image[bottom + 1:min(bottom + wall_height + 1, image.shape[0]), x] = color
|
||||
grain = rng.integers(-7, 8, size=image.shape[:2] + (1,), dtype=np.int16)
|
||||
opaque = image[..., 3] != 0
|
||||
rgb = image[..., :3].astype(np.int16)
|
||||
image[..., :3] = np.where(opaque[..., None], np.clip(rgb + grain, 0, 255), rgb).astype(np.uint8)
|
||||
return image, (width // 2, top_height + wall_height - 1)
|
||||
|
||||
def decal(params: dict[str, Any], rng: np.random.Generator) -> tuple[RgbaArray, tuple[int, int]]:
|
||||
_reject_unknown(params,{"width","height","color","irregularity","variants","dither"})
|
||||
width = _positive_int(params, "width", 48)
|
||||
height = _positive_int(params, "height", 24)
|
||||
color = _color(params, "color", "#707070C0")
|
||||
irregularity = float(params.get("irregularity", .18))
|
||||
yy, xx = np.mgrid[0:height, 0:width]
|
||||
nx=(xx+.5-width/2)/(width/2); ny=(yy+.5-height/2)/(height/2)
|
||||
angle=np.arctan2(ny,nx); radius=np.sqrt(nx*nx+ny*ny)
|
||||
phase=rng.uniform(0,math.tau); boundary=1+irregularity*np.sin(angle*5+phase)+rng.normal(0,.035,(height,width))
|
||||
mask=radius<=boundary
|
||||
image=np.zeros((height,width,4),dtype=np.uint8);image[mask]=color
|
||||
alpha_scale=np.clip((boundary-radius)*5,0,1)
|
||||
image[...,3]=np.where(mask,(image[...,3].astype(float)*alpha_scale).astype(np.uint8),0)
|
||||
return image,(width//2,height//2)
|
||||
|
||||
register_generator("floor_tile", floor_tile)
|
||||
register_generator("wall_tile", wall_tile)
|
||||
register_generator("decal", decal)
|
||||
|
||||
class ProceduralBackend(Backend):
|
||||
name = "procedural"
|
||||
version = "1.1.0"
|
||||
|
||||
def generate(self, spec, context: BackendContext) -> RawAsset:
|
||||
# Имя генератора решает, каким из двух путей идти. Реестры не
|
||||
# пересекаются (см. _check_free), поэтому выбор однозначен.
|
||||
sequence = _sequence_generators.get(spec.generator)
|
||||
if sequence is not None:
|
||||
asset = sequence(spec, np.random.default_rng(_variant_seed(spec.seed, 0)))
|
||||
return validate_sequence_asset(asset, spec)
|
||||
try:
|
||||
generator = _generators[spec.generator]
|
||||
except KeyError as error:
|
||||
raise ValueError(f"unknown procedural generator '{spec.generator}'; available: "
|
||||
f"{', '.join(generator_names())}; sequences: "
|
||||
f"{', '.join(sequence_generator_names()) or '<none>'}") from error
|
||||
# Однокадровому генератору нечем повернуть фигуру: он отдаёт одну
|
||||
# картинку, поэтому больше одного направления у него быть не может.
|
||||
if spec.dirs != 1:
|
||||
raise ValueError(f"single-frame procedural generator '{spec.generator}' requires dirs: 1")
|
||||
variants = _positive_int(spec.params, "variants", 1, 256)
|
||||
frames=[]
|
||||
for variant in range(variants):
|
||||
rgba,pivot=generator(spec.params,np.random.default_rng(_variant_seed(spec.seed, variant)))
|
||||
frames.append(RawFrame(rgba,pivot[0],pivot[1],max(1,round(1000/spec.fps))))
|
||||
return RawAsset(tuple(frames),(0,),"default",1)
|
||||
|
||||
register_backend("procedural", ProceduralBackend)
|
||||
20
src/spriteforge/backends/registry.py
Normal file
20
src/spriteforge/backends/registry.py
Normal file
@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from .base import Backend
|
||||
|
||||
BackendFactory = Callable[[], Backend]
|
||||
_factories: dict[str, BackendFactory] = {}
|
||||
|
||||
def register_backend(name: str, factory: BackendFactory, *, replace: bool = False) -> None:
|
||||
if not name or (name in _factories and not replace):
|
||||
raise ValueError(f"backend implementation already registered or invalid: {name}")
|
||||
_factories[name] = factory
|
||||
|
||||
def create_backend(name: str) -> Backend:
|
||||
try:
|
||||
return _factories[name]()
|
||||
except KeyError as error:
|
||||
raise ValueError(f"backend '{name}' is not installed") from error
|
||||
|
||||
def implemented_backends() -> tuple[str, ...]:
|
||||
return tuple(sorted(_factories))
|
||||
148
src/spriteforge/backends/sequence.py
Normal file
148
src/spriteforge/backends/sequence.py
Normal file
@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import RawAsset, RawFrame
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from spriteforge.manifest import AssetSpec
|
||||
|
||||
FULL_TURN_CENTIDEGREES = 36000
|
||||
NAMED_PIVOTS = ("bottom_center", "center", "bottom_left", "top_left")
|
||||
|
||||
|
||||
def direction_centidegrees(count: int) -> tuple[int, ...]:
|
||||
"""Углы N направлений в сотых долях градуса: индекс 0 — это 0, дальше по кругу.
|
||||
|
||||
Та же формула, что у Blender-драйвера, иначе один и тот же ассет,
|
||||
собранный разными бэкендами, смотрел бы в разные стороны.
|
||||
"""
|
||||
if count < 1:
|
||||
raise ValueError("direction count must be at least 1")
|
||||
return tuple(round(index * FULL_TURN_CENTIDEGREES / count) for index in range(count))
|
||||
|
||||
|
||||
def duration_from_fps(fps: float) -> int:
|
||||
"""Длительность кадра в миллисекундах; ноль недопустим — формат хранит uint16."""
|
||||
if fps <= 0:
|
||||
raise ValueError("fps must be positive")
|
||||
return max(1, round(1000 / fps))
|
||||
|
||||
|
||||
def resolve_pivot(pivot: str, width: int, height: int) -> tuple[int, int]:
|
||||
"""Именованный или явный 'x,y' пивот в пиксели кадра."""
|
||||
named = {"bottom_center": (width // 2, height - 1), "center": (width // 2, height // 2),
|
||||
"bottom_left": (0, height - 1), "top_left": (0, 0)}
|
||||
if pivot in named:
|
||||
return named[pivot]
|
||||
try:
|
||||
x, y = pivot.split(",")
|
||||
return int(x), int(y)
|
||||
except (AttributeError, ValueError) as error:
|
||||
raise ValueError(f"pivot must be one of {', '.join(NAMED_PIVOTS)} or 'x,y'") from error
|
||||
|
||||
|
||||
def frame_index(animations: Sequence[tuple[str, int]], direction_count: int, animation: str,
|
||||
direction: int, frame: int) -> int:
|
||||
"""Единственное место, где живёт раскладка кадров .sfa.
|
||||
|
||||
Анимации лежат подряд, а внутри анимации порядок направленчески-мажорный:
|
||||
сначала все кадры направления 0, затем направления 1 и так далее. Ошибка
|
||||
здесь не падает, а тихо разворачивает спрайты не туда, поэтому формула
|
||||
описана ровно один раз, и сборка ассета тоже раскладывает кадры через неё.
|
||||
"""
|
||||
if direction_count < 1:
|
||||
raise ValueError("direction count must be at least 1")
|
||||
first = 0
|
||||
for name, per_direction in animations:
|
||||
if name != animation:
|
||||
first += per_direction * direction_count
|
||||
continue
|
||||
if not 0 <= direction < direction_count:
|
||||
raise ValueError(f"direction {direction} is outside 0..{direction_count - 1}")
|
||||
if not 0 <= frame < per_direction:
|
||||
raise ValueError(f"frame {frame} is outside 0..{per_direction - 1} of animation '{animation}'")
|
||||
return first + direction * per_direction + frame
|
||||
declared = ", ".join(name for name, _ in animations) or "<none>"
|
||||
raise ValueError(f"unknown animation '{animation}'; declared: {declared}")
|
||||
|
||||
|
||||
def build_sequence_asset(animations: Mapping[str, Sequence[Sequence[RawFrame]]], directions: Sequence[int], *,
|
||||
layer_count: int = 1, layer_orders: Sequence[Sequence[int]] | None = None,
|
||||
warnings: Sequence[str] = ()) -> RawAsset:
|
||||
"""Собирает RawAsset из {анимация: [направление][кадр]}.
|
||||
|
||||
Порядок анимаций — порядок ключей в переданном отображении, поэтому dict
|
||||
должен быть упорядочен так, как автор хочет видеть анимации в файле.
|
||||
"""
|
||||
angles = tuple(int(angle) for angle in directions)
|
||||
if not angles:
|
||||
raise ValueError("a sequence asset needs at least one direction")
|
||||
if not animations:
|
||||
raise ValueError("a sequence asset needs at least one animation")
|
||||
rows: dict[str, tuple[tuple[RawFrame, ...], ...]] = {}
|
||||
specs: list[tuple[str, int]] = []
|
||||
for name, per_direction in animations.items():
|
||||
if not isinstance(name, str) or not name:
|
||||
raise ValueError("animation names must be non-empty strings")
|
||||
by_direction = tuple(tuple(frames) for frames in per_direction)
|
||||
if len(by_direction) != len(angles):
|
||||
raise ValueError(f"animation '{name}' carries {len(by_direction)} direction(s) "
|
||||
f"while the asset declares {len(angles)}")
|
||||
count = len(by_direction[0])
|
||||
if count < 1:
|
||||
raise ValueError(f"animation '{name}' has no frames")
|
||||
if any(len(frames) != count for frames in by_direction):
|
||||
raise ValueError(f"animation '{name}' must have the same frame count in every direction")
|
||||
if any(not isinstance(frame, RawFrame) for frames in by_direction for frame in frames):
|
||||
raise TypeError(f"animation '{name}' must contain RawFrame objects")
|
||||
rows[name] = by_direction
|
||||
specs.append((name, count))
|
||||
total = sum(count * len(angles) for _, count in specs)
|
||||
ordered: list[RawFrame | None] = [None] * total
|
||||
for name, by_direction in rows.items():
|
||||
for direction, frames in enumerate(by_direction):
|
||||
for number, frame in enumerate(frames):
|
||||
ordered[frame_index(specs, len(angles), name, direction, number)] = frame
|
||||
if any(frame is None for frame in ordered):
|
||||
raise RuntimeError("frame layout left holes; animation table and frame lists disagree")
|
||||
orders = tuple(tuple(int(value) for value in order) for order in layer_orders) if layer_orders is not None else ()
|
||||
if orders and len(orders) != total:
|
||||
raise ValueError(f"layer_orders describes {len(orders)} of {total} frames")
|
||||
return RawAsset(tuple(frame for frame in ordered if frame is not None), angles, specs[0][0],
|
||||
layer_count, tuple(specs), orders, tuple(warnings))
|
||||
|
||||
|
||||
def validate_sequence_asset(asset: RawAsset, spec: "AssetSpec") -> RawAsset:
|
||||
"""Ловит расхождение генератора с манифестом до кодирования .sfa.
|
||||
|
||||
Кодек тоже проверяет размеры, но его сообщения ничего не говорят о том,
|
||||
какой генератор соврал, а промах в раскладке кадров стоит дорого.
|
||||
"""
|
||||
where = f"sequence generator '{spec.generator}'"
|
||||
if not isinstance(asset, RawAsset):
|
||||
raise TypeError(f"{where} must return a RawAsset")
|
||||
directions = asset.directions_centidegrees
|
||||
if len(directions) != spec.dirs:
|
||||
raise ValueError(f"{where} returned {len(directions)} direction(s) "
|
||||
f"but the manifest asks for dirs: {spec.dirs}")
|
||||
if not asset.frames:
|
||||
raise ValueError(f"{where} returned no frames")
|
||||
animations = asset.animations or ((asset.animation, len(asset.frames) // len(directions)),)
|
||||
names = [name for name, _ in animations]
|
||||
if any(not name for name in names) or any(count < 1 for _, count in animations):
|
||||
raise ValueError(f"{where} returned an animation without a name or without frames")
|
||||
if len(set(names)) != len(names):
|
||||
raise ValueError(f"{where} returned duplicate animation names: {', '.join(names)}")
|
||||
expected = sum(count * len(directions) for _, count in animations)
|
||||
if expected != len(asset.frames):
|
||||
raise ValueError(f"{where} returned {len(asset.frames)} frames "
|
||||
f"while its animations describe {expected}")
|
||||
missing = [name for name in spec.anims if name not in names]
|
||||
if missing:
|
||||
raise ValueError(f"{where} did not produce animations requested by the manifest: {', '.join(missing)}")
|
||||
if asset.layer_orders and len(asset.layer_orders) != len(asset.frames):
|
||||
raise ValueError(f"{where} returned layer orders for {len(asset.layer_orders)} "
|
||||
f"of {len(asset.frames)} frames")
|
||||
return asset
|
||||
116
src/spriteforge/build.py
Normal file
116
src/spriteforge/build.py
Normal file
@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from .backends import RawAsset
|
||||
from .cache import RawCacheResult,artifact_path,load_or_generate,write_artifact
|
||||
from .format import Animation,encode_sfa
|
||||
from .manifest import AssetEntry
|
||||
from .palette import decode_sfp,encode_sfp
|
||||
from .postprocess import POSTPROCESS_VERSION,build_palette,encode_frames,normalize_asset
|
||||
from .reader import decode_sfa
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BuildResult:
|
||||
output_dir:Path
|
||||
asset_count:int
|
||||
files:tuple[Path,...]
|
||||
generated:int=0
|
||||
raw_cache_hits:int=0
|
||||
artifact_cache_hits:int=0
|
||||
|
||||
def _worker(arguments:tuple[AssetEntry,str])->RawCacheResult:
|
||||
entry,cache=arguments
|
||||
return load_or_generate(entry,Path(cache))
|
||||
|
||||
def _atomic(path:Path,data:bytes)->None:
|
||||
path.parent.mkdir(parents=True,exist_ok=True)
|
||||
temporary=path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
temporary.write_bytes(data);temporary.replace(path)
|
||||
|
||||
def _existing_palette(output_dir:Path)->tuple[list[tuple[int,int,int]],bytes]|None:
|
||||
path=output_dir/"palettes.sfp"
|
||||
if not path.is_file():return None
|
||||
blob=path.read_bytes();pack=decode_sfp(blob)
|
||||
if "default" not in pack.palettes:raise ValueError("existing palette pack has no 'default' palette; use --rebuild-palette")
|
||||
raw=pack.palettes["default"]
|
||||
return ([tuple(raw[i:i+3]) for i in range(0,768,3)],blob)
|
||||
|
||||
def _old_assets(output_dir:Path)->list[dict]:
|
||||
path=output_dir/"index.json"
|
||||
if not path.is_file():return []
|
||||
try:data=json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError,json.JSONDecodeError):return []
|
||||
return data.get("assets",[]) if data.get("version")==1 and isinstance(data.get("assets"),list) else []
|
||||
|
||||
def build_assets(entries:Iterable[AssetEntry],output_dir:Path,*,cache_dir:Path=Path(".sfcache"),jobs:int=1,
|
||||
rebuild_palette:bool=False,preserve_existing:bool=False)->BuildResult:
|
||||
selected=tuple(entries)
|
||||
if not selected:raise ValueError("no assets selected")
|
||||
output_dir=output_dir.resolve();cache_dir=cache_dir.resolve();jobs=max(1,jobs)
|
||||
arguments=[(entry,str(cache_dir)) for entry in selected]
|
||||
if jobs==1:
|
||||
cached=[_worker(argument) for argument in arguments]
|
||||
else:
|
||||
with ProcessPoolExecutor(max_workers=min(jobs,len(selected))) as pool:
|
||||
cached=list(pool.map(_worker,arguments))
|
||||
normalized=[normalize_asset(result.asset,entry.spec.size,entry.spec.postprocess_scale)
|
||||
for entry,result in zip(selected,cached)]
|
||||
existing=None if rebuild_palette else _existing_palette(output_dir)
|
||||
if existing is None:
|
||||
palette=build_palette(normalized);palette_blob=encode_sfp({"default":palette})
|
||||
else:
|
||||
palette,palette_blob=existing
|
||||
palette_digest=hashlib.sha256(palette_blob).hexdigest()
|
||||
output_dir.mkdir(parents=True,exist_ok=True);files=[]
|
||||
palette_path=output_dir/"palettes.sfp";_atomic(palette_path,palette_blob);files.append(palette_path)
|
||||
replaced={entry.id for entry in selected}
|
||||
index_assets=[item for item in _old_assets(output_dir) if preserve_existing and item.get("id") not in replaced]
|
||||
artifact_hits=0
|
||||
for entry,raw,raw_result in zip(selected,normalized,cached):
|
||||
artifact_fingerprint=json.dumps({"input_hash":raw_result.input_hash,"palette_hash":palette_digest,
|
||||
"postprocess_version":POSTPROCESS_VERSION,"postprocess_scale":entry.spec.postprocess_scale},
|
||||
sort_keys=True,separators=(",",":")).encode()
|
||||
digest=hashlib.sha256(artifact_fingerprint).hexdigest();cached_artifact=artifact_path(cache_dir,digest)
|
||||
if cached_artifact.is_file():
|
||||
blob=cached_artifact.read_bytes()
|
||||
try:sfa=decode_sfa(blob)
|
||||
except ValueError:
|
||||
cached_artifact.unlink(missing_ok=True);blob=b"";sfa=None
|
||||
if blob:artifact_hits+=1;cache_status="artifact-cache"
|
||||
else:blob=b"";sfa=None
|
||||
if not blob:
|
||||
frames=encode_frames(raw,palette,bool(entry.spec.params.get("dither",False)))
|
||||
animations=tuple(Animation(name,count) for name,count in raw.animations) if raw.animations else None
|
||||
orders=raw.layer_orders or None
|
||||
blob=encode_sfa(frames,animation=raw.animation,directions=raw.directions_centidegrees,
|
||||
layer_count=raw.layer_count,animations=animations,layer_orders=orders)
|
||||
write_artifact(cache_dir,digest,blob);sfa=decode_sfa(blob);cache_status=raw_result.status
|
||||
assert sfa is not None
|
||||
frames=sfa.frames;path=output_dir/f"{entry.id}.sfa";_atomic(path,blob);files.append(path)
|
||||
content_digest=hashlib.sha256(blob).hexdigest()
|
||||
snapshot={"id":entry.id,"backend":entry.spec.backend,"backend_version":raw_result.backend_version,
|
||||
"postprocess_version":POSTPROCESS_VERSION,"input_hash":raw_result.input_hash,
|
||||
"frames":len(frames),"directions":len(sfa.directions),"sizes":[[f.width,f.height] for f in frames],
|
||||
"pivots":[[f.pivot_x,f.pivot_y] for f in frames],"durations_ms":[f.duration_ms for f in frames],
|
||||
"animation":sfa.animation,"animations":[{"name":x[0],"first_frame":x[1],"frames_per_direction":x[2]} for x in sfa.animations],
|
||||
"layer_count":raw.layer_count,"layer_orders":[list(x) for x in sfa.layer_orders],"warnings":list(raw.warnings),
|
||||
"streams":{"normals":all(f.normals_xy is not None for f in frames),"depth":all(f.depth is not None for f in frames)},
|
||||
"tags":entry.spec.tags,"spec":entry.spec.model_dump(mode="json"),
|
||||
"source_manifest":str(entry.manifest),"file":path.name,"bytes":len(blob),"hash":digest,
|
||||
"content_hash":content_digest,"palette_hash":palette_digest,"status":"ok"}
|
||||
index_assets.append(snapshot)
|
||||
history=output_dir/".sfmeta"/entry.id
|
||||
_atomic(history/f"{digest}.json",(json.dumps(snapshot,sort_keys=True,indent=2)+"\n").encode())
|
||||
index_assets.sort(key=lambda item:item["id"])
|
||||
generated=sum(result.status=="generated" for result in cached)
|
||||
raw_hits=sum(result.status=="raw-cache" for result in cached)
|
||||
index={"version":1,"assets":index_assets,"palette":{"file":"palettes.sfp","hash":palette_digest,"bytes":len(palette_blob)}}
|
||||
index_path=output_dir/"index.json";_atomic(index_path,(json.dumps(index,sort_keys=True,indent=2)+"\n").encode());files.append(index_path)
|
||||
return BuildResult(output_dir,len(selected),tuple(files),generated,raw_hits,artifact_hits)
|
||||
102
src/spriteforge/cache.py
Normal file
102
src/spriteforge/cache.py
Normal file
@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .backends import BackendContext,RawAsset,RawFrame,create_backend
|
||||
from .manifest import AssetEntry
|
||||
|
||||
RAW_CACHE_VERSION="1"
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RawCacheResult:
|
||||
asset:RawAsset
|
||||
input_hash:str
|
||||
backend_version:str
|
||||
status:str
|
||||
|
||||
def _hash_file(hasher, path:Path)->None:
|
||||
resolved=path.resolve()
|
||||
if not resolved.is_file():raise ValueError(f"backend dependency does not exist: {resolved}")
|
||||
hasher.update(str(resolved).encode("utf-8"));hasher.update(b"\0")
|
||||
with resolved.open("rb") as stream:
|
||||
while chunk:=stream.read(1024*1024):hasher.update(chunk)
|
||||
|
||||
def _input_hash(entry:AssetEntry,*,legacy:bool=False)->tuple[str,str]:
|
||||
backend=create_backend(entry.spec.backend);context=BackendContext(entry.manifest.parent,entry.id)
|
||||
hasher=hashlib.sha256();hasher.update(RAW_CACHE_VERSION.encode());hasher.update(b"\0")
|
||||
hasher.update(backend.version.encode());hasher.update(b"\0")
|
||||
payload=entry.spec.model_dump(mode="json")
|
||||
if not legacy:
|
||||
# Catalog/search metadata cannot affect backend pixels. Keeping it out
|
||||
# of the expensive raw hash lets users retag a Blender library without
|
||||
# launching Blender again. Palette selection is an artifact concern.
|
||||
for key in ("tags","palette","palettes","postprocess_scale"):payload.pop(key,None)
|
||||
hasher.update(b"render-spec-v1\0")
|
||||
hasher.update(json.dumps(payload,sort_keys=True,separators=(",",":")).encode())
|
||||
dependencies=sorted(backend.dependencies(entry.spec,context),key=lambda path:str(path.resolve()))
|
||||
for dependency in dependencies:_hash_file(hasher,dependency)
|
||||
return hasher.hexdigest(),backend.version
|
||||
|
||||
def input_hash(entry:AssetEntry)->tuple[str,str]:
|
||||
return _input_hash(entry)
|
||||
|
||||
def _raw_bytes(asset:RawAsset)->bytes:
|
||||
metadata={"animation":asset.animation,"directions":asset.directions_centidegrees,"layer_count":asset.layer_count,
|
||||
"animations":asset.animations,"layer_orders":asset.layer_orders,"warnings":asset.warnings,
|
||||
"frames":[{"pivot_x":f.pivot_x,"pivot_y":f.pivot_y,"duration_ms":f.duration_ms,
|
||||
"depth_min":f.depth_min,"depth_max":f.depth_max} for f in asset.frames]}
|
||||
arrays={f"frame_{i}":frame.rgba for i,frame in enumerate(asset.frames)}
|
||||
for i,frame in enumerate(asset.frames):
|
||||
if frame.normals_xy is not None:arrays[f"normal_{i}"]=frame.normals_xy
|
||||
if frame.depth is not None:arrays[f"depth_{i}"]=frame.depth
|
||||
arrays["metadata"]=np.frombuffer(json.dumps(metadata,sort_keys=True,separators=(",",":")).encode(),dtype=np.uint8)
|
||||
output=io.BytesIO();np.savez_compressed(output,**arrays);return output.getvalue()
|
||||
|
||||
def _read_raw(path:Path)->RawAsset:
|
||||
try:
|
||||
with np.load(path,allow_pickle=False) as archive:
|
||||
metadata=json.loads(archive["metadata"].tobytes())
|
||||
frames=[]
|
||||
for i,item in enumerate(metadata["frames"]):
|
||||
rgba=np.array(archive[f"frame_{i}"],dtype=np.uint8,copy=True)
|
||||
normal=np.array(archive[f"normal_{i}"],dtype=np.uint8,copy=True) if f"normal_{i}" in archive else None
|
||||
depth=np.array(archive[f"depth_{i}"],dtype=np.uint8,copy=True) if f"depth_{i}" in archive else None
|
||||
frames.append(RawFrame(rgba,item["pivot_x"],item["pivot_y"],item["duration_ms"],item["depth_min"],item["depth_max"],normal,depth))
|
||||
except (OSError,ValueError,KeyError,json.JSONDecodeError) as error:raise ValueError(f"invalid raw cache entry {path}: {error}") from error
|
||||
return RawAsset(tuple(frames),tuple(metadata["directions"]),metadata["animation"],metadata["layer_count"],
|
||||
tuple(tuple(x) for x in metadata.get("animations",())),
|
||||
tuple(tuple(x) for x in metadata.get("layer_orders",())),tuple(metadata.get("warnings",())))
|
||||
|
||||
def _atomic_write(path:Path,data:bytes)->None:
|
||||
path.parent.mkdir(parents=True,exist_ok=True);temporary=path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
temporary.write_bytes(data);temporary.replace(path)
|
||||
|
||||
def load_or_generate(entry:AssetEntry,cache_dir:Path)->RawCacheResult:
|
||||
digest,backend_version=input_hash(entry);path=cache_dir/"raw"/f"{digest}.npz"
|
||||
if path.is_file():
|
||||
try:return RawCacheResult(_read_raw(path),digest,backend_version,"raw-cache")
|
||||
except ValueError:path.unlink(missing_ok=True)
|
||||
# One-time migration from SpriteForge <=1.0 raw keys, avoiding a costly
|
||||
# rerender when the cache layout changes but backend inputs do not.
|
||||
legacy_digest,_=_input_hash(entry,legacy=True);legacy_path=cache_dir/"raw"/f"{legacy_digest}.npz"
|
||||
if legacy_path.is_file():
|
||||
try:
|
||||
asset=_read_raw(legacy_path);_atomic_write(path,_raw_bytes(asset))
|
||||
return RawCacheResult(asset,digest,backend_version,"raw-cache")
|
||||
except ValueError:legacy_path.unlink(missing_ok=True)
|
||||
backend=create_backend(entry.spec.backend);context=BackendContext(entry.manifest.parent,entry.id)
|
||||
asset=backend.generate(entry.spec,context);_atomic_write(path,_raw_bytes(asset))
|
||||
return RawCacheResult(asset,digest,backend_version,"generated")
|
||||
|
||||
def artifact_path(cache_dir:Path,digest:str)->Path:
|
||||
return cache_dir/"artifacts"/f"{digest}.sfa"
|
||||
|
||||
def write_artifact(cache_dir:Path,digest:str,data:bytes)->None:
|
||||
_atomic_write(artifact_path(cache_dir,digest),data)
|
||||
260
src/spriteforge/cli.py
Normal file
260
src/spriteforge/cli.py
Normal file
@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import json
|
||||
from typing import Optional
|
||||
import typer
|
||||
from . import __version__
|
||||
from .manifest import ManifestInvalid, load_library
|
||||
|
||||
app = typer.Typer(help="Standalone deterministic 2D sprite asset pipeline.", no_args_is_help=True)
|
||||
gpu_app = typer.Typer(help="Configure remote GPU workers.", no_args_is_help=True)
|
||||
app.add_typer(gpu_app, name="gpu")
|
||||
relay_app = typer.Typer(help="Run the asynchronous HTTPS GPU relay and pull worker.", no_args_is_help=True)
|
||||
app.add_typer(relay_app, name="relay")
|
||||
|
||||
def _library(paths: list[Path]):
|
||||
try:
|
||||
return load_library(paths)
|
||||
except ManifestInvalid as error:
|
||||
for item in error.errors:
|
||||
typer.echo(str(item), err=True)
|
||||
raise typer.Exit(1) from error
|
||||
|
||||
@app.command()
|
||||
def version() -> None:
|
||||
"""Print the SpriteForge version."""
|
||||
typer.echo(__version__)
|
||||
|
||||
@app.command()
|
||||
def studio(project: Path = typer.Argument(..., file_okay=False),
|
||||
init: bool = typer.Option(False, "--init", help="Create a Studio project first."),
|
||||
project_id: str = typer.Option("sprite_project", "--id"),
|
||||
name: str = typer.Option("SpriteForge Project", "--name"),
|
||||
host: str = typer.Option("127.0.0.1", "--host"),
|
||||
port: int = typer.Option(8765, "--port", min=0, max=65535),
|
||||
open_browser: bool = typer.Option(True, "--open/--no-open"),
|
||||
gpu_profile: Optional[str] = typer.Option(None, "--gpu-profile")) -> None:
|
||||
"""Open the local visual asset authoring studio."""
|
||||
from .studio.server import run_studio
|
||||
from .studio.store import ProjectStore
|
||||
try:
|
||||
store = ProjectStore.create(project, project_id, name) if init else ProjectStore(project)
|
||||
store.load()
|
||||
except ValueError as error:
|
||||
typer.echo(str(error), err=True)
|
||||
raise typer.Exit(1) from error
|
||||
providers = {}
|
||||
if gpu_profile:
|
||||
from .studio.config import get_profile, provider_from_profile
|
||||
try:
|
||||
profile=get_profile(gpu_profile);providers[profile.kind] = provider_from_profile(profile)
|
||||
except ValueError as error: typer.echo(str(error),err=True);raise typer.Exit(1) from error
|
||||
run_studio(store, host, port, open_browser, providers)
|
||||
|
||||
@app.command("studio-validate")
|
||||
def studio_validate(project:Path=typer.Argument(...,file_okay=False)) -> None:
|
||||
"""Validate a Studio project without opening any images."""
|
||||
from .studio.store import ProjectStore
|
||||
from .studio.validation import validate_project,validation_text
|
||||
try:store=ProjectStore(project);issues=validate_project(store);typer.echo(validation_text(store))
|
||||
except ValueError as error:typer.echo(str(error),err=True);raise typer.Exit(1) from error
|
||||
if any(issue.severity=="error" for issue in issues):raise typer.Exit(1)
|
||||
|
||||
@gpu_app.command("add")
|
||||
def gpu_add(name: str, url: str = typer.Option(...,"--url"),
|
||||
workflow: Path = typer.Option(...,"--workflow",exists=True,dir_okay=False),
|
||||
token_env: str = typer.Option("","--token-env"), timeout: float = typer.Option(900,"--timeout"),
|
||||
kind:str=typer.Option("comfyui","--kind",help="comfyui or relay")) -> None:
|
||||
"""Save a ComfyUI connection profile; secrets remain in environment variables."""
|
||||
from .studio.config import GpuProfile, save_profile
|
||||
from .studio.providers import require_valid_workflow
|
||||
try:require_valid_workflow(workflow)
|
||||
except ValueError as error:typer.echo(str(error),err=True);raise typer.Exit(1) from error
|
||||
profile=GpuProfile(name=name,kind=kind,endpoint=url,workflow=str(workflow.resolve()),token_env=token_env,timeout_seconds=timeout)
|
||||
typer.echo(f"saved {name} in {save_profile(profile)}")
|
||||
|
||||
@gpu_app.command("workflow-check")
|
||||
def gpu_workflow_check(workflow:Path=typer.Argument(...,exists=True,dir_okay=False))->None:
|
||||
"""Validate a ComfyUI API workflow before spending GPU time."""
|
||||
from .studio.providers import workflow_issues
|
||||
issues=workflow_issues(workflow)
|
||||
if issues:
|
||||
for issue in issues:typer.echo(f"ERROR: {issue}",err=True)
|
||||
raise typer.Exit(1)
|
||||
typer.echo("OK: ComfyUI API workflow and SpriteForge placeholders are valid")
|
||||
|
||||
@gpu_app.command("list")
|
||||
def gpu_list() -> None:
|
||||
"""List configured workers without exposing tokens."""
|
||||
from .studio.config import load_config
|
||||
for profile in load_config().profiles:typer.echo(f"{profile.name}\t{profile.kind}\t{profile.endpoint}\t{profile.workflow}\ttoken-env={profile.token_env or '-'}")
|
||||
|
||||
@gpu_app.command("test")
|
||||
def gpu_test(name: str) -> None:
|
||||
"""Test a configured ComfyUI worker."""
|
||||
from .studio.config import get_profile,provider_from_profile
|
||||
try:stats=provider_from_profile(get_profile(name)).check()
|
||||
except (ValueError,OSError) as error:typer.echo(f"connection failed: {error}",err=True);raise typer.Exit(1) from error
|
||||
typer.echo(f"OK: {name} ({json.dumps(stats,ensure_ascii=False)[:500]})")
|
||||
|
||||
def _secret(name:str)->str:
|
||||
import os
|
||||
value=os.environ.get(name,"")
|
||||
if not value:typer.echo(f"environment variable is empty: {name}",err=True);raise typer.Exit(1)
|
||||
return value
|
||||
|
||||
@relay_app.command("serve")
|
||||
def relay_serve(data:Path=typer.Option(Path("relay-data"),"--data",file_okay=False),host:str=typer.Option("127.0.0.1","--host"),
|
||||
port:int=typer.Option(8787,"--port",min=1,max=65535),client_token_env:str=typer.Option("SF_RELAY_CLIENT_TOKEN","--client-token-env"),
|
||||
worker_token_env:str=typer.Option("SF_RELAY_WORKER_TOKEN","--worker-token-env"),ttl_hours:int=typer.Option(72,"--ttl-hours",min=1)) -> None:
|
||||
"""Run the small public relay service (put HTTPS reverse proxy in front)."""
|
||||
from .relay.server import serve
|
||||
serve(data,host,port,_secret(client_token_env),_secret(worker_token_env),ttl_hours)
|
||||
|
||||
@relay_app.command("worker")
|
||||
def relay_worker(url:str=typer.Option(...,"--url"),token_env:str=typer.Option("SF_RELAY_WORKER_TOKEN","--token-env"),
|
||||
comfy_url:str=typer.Option("http://127.0.0.1:8188","--comfy-url"),worker_id:str=typer.Option("","--worker-id"),
|
||||
poll:float=typer.Option(2,"--poll",min=.2),once:bool=typer.Option(False,"--once")) -> None:
|
||||
"""Poll a relay from the GPU PC and execute jobs in local ComfyUI."""
|
||||
from .relay.worker import GpuWorker
|
||||
worker=GpuWorker(url,_secret(token_env),comfy_url,worker_id)
|
||||
if once:worker.once()
|
||||
else:
|
||||
try:worker.run(poll)
|
||||
except KeyboardInterrupt:typer.echo("worker stopped")
|
||||
|
||||
@app.command()
|
||||
def validate(manifest: list[Path] = typer.Argument(None),
|
||||
build_dir: Path = typer.Option(Path("build"),"--build-dir",file_okay=False)) -> None:
|
||||
"""Validate manifests and, when present, the generated catalog."""
|
||||
assets=_library(manifest) if manifest else ()
|
||||
if manifest:typer.echo(f"OK: {len(manifest)} manifest(s), {len(assets)} asset(s)")
|
||||
if not (build_dir/"index.json").is_file():
|
||||
if manifest:return
|
||||
typer.echo(f"catalog not found: {(build_dir/'index.json').resolve()}",err=True);raise typer.Exit(1)
|
||||
from .introspection import Catalog,validate_catalog
|
||||
try:issues=validate_catalog(Catalog(build_dir),assets)
|
||||
except ValueError as error:typer.echo(f"ERROR: {error}",err=True);raise typer.Exit(1) from error
|
||||
for issue in issues:typer.echo(str(issue))
|
||||
errors=sum(issue.severity=="error" for issue in issues);warnings=sum(issue.severity=="warning" for issue in issues)
|
||||
typer.echo(f"validation: {errors} error(s), {warnings} warning(s)")
|
||||
if errors:raise typer.Exit(1)
|
||||
|
||||
@app.command()
|
||||
def build(manifest: list[Path] = typer.Argument(..., exists=True, dir_okay=False),
|
||||
asset: Optional[str] = typer.Option(None, "--asset", help="Build one asset id."),
|
||||
tag: Optional[str] = typer.Option(None, "--tag"),
|
||||
backend: Optional[str] = typer.Option(None, "--backend"),
|
||||
jobs: int = typer.Option(1, "--jobs", min=1),
|
||||
dry_run: bool = typer.Option(False, "--dry-run"),
|
||||
output: Path = typer.Option(Path("build"), "--output", file_okay=False),
|
||||
cache_dir:Path=typer.Option(Path(".sfcache"),"--cache-dir",file_okay=False),
|
||||
rebuild_palette:bool=typer.Option(False,"--rebuild-palette")) -> None:
|
||||
"""Build selected assets or print the task plan."""
|
||||
assets = _library(manifest)
|
||||
selected = [item for item in assets if (asset is None or item.id == asset)
|
||||
and (tag is None or tag in item.spec.tags)
|
||||
and (backend is None or item.spec.backend == backend)]
|
||||
if asset is not None and not selected:
|
||||
typer.echo(f"asset not found: {asset}", err=True); raise typer.Exit(1)
|
||||
if rebuild_palette and (asset is not None or tag is not None or backend is not None):
|
||||
typer.echo("--rebuild-palette requires an unfiltered full build",err=True);raise typer.Exit(2)
|
||||
if dry_run:
|
||||
typer.echo(f"dry-run: {len(selected)} task(s), jobs={jobs}")
|
||||
for item in selected:
|
||||
typer.echo(f"{item.id}\t{item.spec.backend}\tdirs={item.spec.dirs}\tfps={item.spec.fps:g}")
|
||||
return
|
||||
from .backends import implemented_backends
|
||||
available=set(implemented_backends())
|
||||
unsupported=sorted({item.spec.backend for item in selected if item.spec.backend not in available})
|
||||
if unsupported:
|
||||
typer.echo(f"backend implementation is not available in stage 3: {', '.join(unsupported)}",err=True)
|
||||
raise typer.Exit(2)
|
||||
from .build import build_assets
|
||||
try:
|
||||
result=build_assets(selected,output.resolve(),cache_dir=cache_dir,jobs=jobs,rebuild_palette=rebuild_palette,
|
||||
preserve_existing=asset is not None or tag is not None or backend is not None)
|
||||
except ValueError as error:
|
||||
typer.echo(f"build failed: {error}",err=True);raise typer.Exit(1) from error
|
||||
typer.echo(f"built {result.asset_count} asset(s) into {result.output_dir}")
|
||||
typer.echo(f"cache: generated={result.generated} raw={result.raw_cache_hits} artifact={result.artifact_cache_hits}")
|
||||
|
||||
def _future(stage: int) -> None:
|
||||
typer.echo(f"command becomes operational in stage {stage}", err=True)
|
||||
raise typer.Exit(2)
|
||||
|
||||
@app.command("list")
|
||||
def list_assets(tag: Optional[str] = None, backend: Optional[str] = None,
|
||||
build_dir:Path=typer.Option(Path("build"),"--build-dir")) -> None:
|
||||
"""List generated assets without opening images."""
|
||||
from .introspection import Catalog,list_rows,table
|
||||
try:typer.echo(table(list_rows(Catalog(build_dir),tag,backend),("id","backend","frames","dirs","size","hash","status")))
|
||||
except ValueError as error:typer.echo(str(error),err=True);raise typer.Exit(1) from error
|
||||
|
||||
@app.command()
|
||||
def describe(asset_id: str,build_dir:Path=typer.Option(Path("build"),"--build-dir")) -> None:
|
||||
"""Describe one generated asset as text."""
|
||||
from .introspection import Catalog,describe_text
|
||||
try:typer.echo(describe_text(Catalog(build_dir),asset_id))
|
||||
except ValueError as error:typer.echo(str(error),err=True);raise typer.Exit(1) from error
|
||||
|
||||
@app.command()
|
||||
def ascii(asset_id: str,frame:int=typer.Option(0,"--frame",min=0),columns:int=typer.Option(40,"--columns",min=8,max=160),
|
||||
build_dir:Path=typer.Option(Path("build"),"--build-dir")) -> None:
|
||||
"""Print a compact ASCII silhouette preview."""
|
||||
from .introspection import Catalog,ascii_preview
|
||||
try:typer.echo(ascii_preview(Catalog(build_dir),asset_id,frame,columns))
|
||||
except ValueError as error:typer.echo(str(error),err=True);raise typer.Exit(1) from error
|
||||
|
||||
@app.command()
|
||||
def palette(name: str,build_dir:Path=typer.Option(Path("build"),"--build-dir")) -> None:
|
||||
"""Print palette indices and RGB values."""
|
||||
from .introspection import Catalog,palette_text
|
||||
try:typer.echo(palette_text(Catalog(build_dir),name))
|
||||
except ValueError as error:typer.echo(str(error),err=True);raise typer.Exit(1) from error
|
||||
|
||||
@app.command()
|
||||
def diff(asset_id: str,against:str=typer.Option(...,"--against"),build_dir:Path=typer.Option(Path("build"),"--build-dir")) -> None:
|
||||
"""Compare current metadata with a previous build hash."""
|
||||
from .introspection import Catalog,diff_text
|
||||
try:typer.echo(diff_text(Catalog(build_dir),asset_id,against))
|
||||
except (ValueError,OSError) as error:typer.echo(str(error),err=True);raise typer.Exit(1) from error
|
||||
|
||||
@app.command()
|
||||
def stats(build_dir:Path=typer.Option(Path("build"),"--build-dir")) -> None:
|
||||
"""Print generated-library statistics."""
|
||||
from .introspection import Catalog,stats_text
|
||||
try:typer.echo(stats_text(Catalog(build_dir)))
|
||||
except ValueError as error:typer.echo(str(error),err=True);raise typer.Exit(1) from error
|
||||
|
||||
@app.command("contact-sheet")
|
||||
def contact_sheet(output:Path=typer.Option(Path("contact-sheet.png"),"--output",dir_okay=False),
|
||||
asset:list[str]=typer.Option(None,"--asset"),build_dir:Path=typer.Option(Path("build"),"--build-dir")) -> None:
|
||||
"""Create a PNG for human review; agents must not open it."""
|
||||
from .introspection import Catalog,contact_sheet as render
|
||||
try:render(Catalog(build_dir),output.resolve(),asset or None);typer.echo(f"wrote human-only contact sheet: {output.resolve()}")
|
||||
except (ValueError,OSError) as error:typer.echo(str(error),err=True);raise typer.Exit(1) from error
|
||||
|
||||
@app.command()
|
||||
def codegen(build_dir:Path=typer.Option(Path("build"),"--build-dir",file_okay=False),
|
||||
output:Path=typer.Option(Path("build/spriteforge_assets.h"),"--output",dir_okay=False)) -> None:
|
||||
"""Generate compile-time C99/C++17 asset, animation and palette IDs."""
|
||||
from .codegen import generate_header
|
||||
try:path=generate_header(build_dir.resolve(),output.resolve())
|
||||
except ValueError as error:typer.echo(str(error),err=True);raise typer.Exit(1) from error
|
||||
typer.echo(f"wrote {path}")
|
||||
|
||||
@app.command()
|
||||
def watch(manifest:list[Path]=typer.Argument(...,exists=True,dir_okay=False),
|
||||
output:Path=typer.Option(Path("build"),"--output",file_okay=False),
|
||||
cache_dir:Path=typer.Option(Path(".sfcache"),"--cache-dir",file_okay=False),
|
||||
jobs:int=typer.Option(1,"--jobs",min=1),interval:float=typer.Option(0.5,"--interval",min=0.05)) -> None:
|
||||
"""Build and watch manifests and their declared source files."""
|
||||
from .watch import run
|
||||
typer.echo("watching; press Ctrl+C to stop")
|
||||
try:run(manifest,output.resolve(),cache_dir.resolve(),jobs,interval)
|
||||
except KeyboardInterrupt:typer.echo("stopped")
|
||||
except (ManifestInvalid,ValueError) as error:typer.echo(str(error),err=True);raise typer.Exit(1) from error
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
60
src/spriteforge/codegen.py
Normal file
60
src/spriteforge/codegen.py
Normal file
@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
def _symbol(value: str) -> str:
|
||||
symbol = re.sub(r"[^A-Za-z0-9]+", "_", value).strip("_").upper()
|
||||
if not symbol or symbol[0].isdigit():
|
||||
symbol = "_" + symbol
|
||||
return symbol
|
||||
|
||||
|
||||
def _fnv1a(value: str) -> int:
|
||||
result = 2166136261
|
||||
for byte in value.encode("utf-8"):
|
||||
result = ((result ^ byte) * 16777619) & 0xFFFFFFFF
|
||||
return result
|
||||
|
||||
|
||||
def generate_header(build_dir: Path, output: Path) -> Path:
|
||||
index_path = build_dir / "index.json"
|
||||
try:
|
||||
catalog = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise ValueError(f"cannot read catalog {index_path}: {error}") from error
|
||||
assets = sorted(catalog.get("assets", ()), key=lambda item: item["id"])
|
||||
animations = sorted({animation["name"] for asset in assets for animation in asset.get("animations", ())})
|
||||
palette_file = build_dir / catalog.get("palette", {}).get("file", "palettes.sfp")
|
||||
from .palette import decode_sfp
|
||||
try:
|
||||
palettes = sorted(decode_sfp(palette_file.read_bytes()).palettes)
|
||||
except (OSError, ValueError) as error:
|
||||
raise ValueError(f"cannot read palette pack {palette_file}: {error}") from error
|
||||
|
||||
lines = [
|
||||
"/* Generated by SpriteForge. Do not edit. */", "#ifndef SPRITEFORGE_ASSETS_H",
|
||||
"#define SPRITEFORGE_ASSETS_H", "#include <stdint.h>", "",
|
||||
"typedef enum sf_asset_id {",
|
||||
]
|
||||
lines += [f" SF_ASSET_{_symbol(item['id'])} = UINT32_C(0x{_fnv1a(item['id']):08X})," for item in assets]
|
||||
lines += ["} sf_asset_id;", "", "typedef enum sf_animation_id {"]
|
||||
lines += [f" SF_ANIM_{_symbol(name)} = UINT32_C(0x{_fnv1a(name):08X})," for name in animations]
|
||||
lines += ["} sf_animation_id;", "", "typedef enum sf_palette_id {"]
|
||||
lines += [f" SF_PALETTE_{_symbol(name)} = UINT32_C(0x{_fnv1a(name):08X})," for name in palettes]
|
||||
lines += [
|
||||
"} sf_palette_id;", "", "typedef struct sf_asset_metadata {",
|
||||
" sf_asset_id id; uint16_t frames; uint16_t directions; uint8_t has_normals; uint8_t has_depth;",
|
||||
"} sf_asset_metadata;", "", f"static const sf_asset_metadata SF_ASSET_METADATA[{len(assets)}] = {{",
|
||||
]
|
||||
for item in assets:
|
||||
streams = item.get("streams", {})
|
||||
lines.append(f" {{SF_ASSET_{_symbol(item['id'])}, {item['frames']}, {item['directions']}, {int(bool(streams.get('normals')))}, {int(bool(streams.get('depth')))}}},")
|
||||
lines += ["};", f"#define SF_ASSET_COUNT {len(assets)}u", "", "#endif", ""]
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = output.with_suffix(output.suffix + ".tmp")
|
||||
temporary.write_text("\n".join(lines), encoding="utf-8", newline="\n")
|
||||
temporary.replace(output)
|
||||
return output
|
||||
151
src/spriteforge/format.py
Normal file
151
src/spriteforge/format.py
Normal file
@ -0,0 +1,151 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
MAGIC = b"SFA\0"
|
||||
VERSION = (1, 0)
|
||||
FLAG_NORMALS = 1 << 0
|
||||
FLAG_DEPTH = 1 << 1
|
||||
FLAG_PALETTE_ALPHA_RESERVED = 1 << 31
|
||||
HEADER = struct.Struct("<4sHHIIHHIHHIIIIIIIII")
|
||||
ANIM = struct.Struct("<IIIHH")
|
||||
DIRECTION = struct.Struct("<hHI")
|
||||
FRAME = struct.Struct("<HHhhHHffIIIIIII")
|
||||
|
||||
|
||||
def fnv1a32(text: str) -> int:
|
||||
value = 0x811C9DC5
|
||||
for byte in text.encode("utf-8"):
|
||||
value = ((value ^ byte) * 0x01000193) & 0xFFFFFFFF
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Frame:
|
||||
indices: bytes
|
||||
width: int
|
||||
height: int
|
||||
pivot_x: int = 0
|
||||
pivot_y: int = 0
|
||||
duration_ms: int = 83
|
||||
normals_xy: bytes | None = None
|
||||
depth: bytes | None = None
|
||||
depth_min: float = 0.0
|
||||
depth_max: float = 1.0
|
||||
|
||||
def validate(self) -> None:
|
||||
pixels = self.width * self.height
|
||||
if not (0 < self.width <= 65535 and 0 < self.height <= 65535):
|
||||
raise ValueError("frame dimensions must be uint16 and nonzero")
|
||||
if len(self.indices) != pixels:
|
||||
raise ValueError("indices length does not match dimensions")
|
||||
if self.normals_xy is not None and len(self.normals_xy) != pixels * 2:
|
||||
raise ValueError("normals length does not match dimensions")
|
||||
if self.depth is not None and len(self.depth) != pixels:
|
||||
raise ValueError("depth length does not match dimensions")
|
||||
if not (-32768 <= self.pivot_x <= 32767 and -32768 <= self.pivot_y <= 32767):
|
||||
raise ValueError("pivot must fit int16")
|
||||
if not (0 <= self.duration_ms <= 65535):
|
||||
raise ValueError("duration must fit uint16 milliseconds")
|
||||
if not (math.isfinite(self.depth_min) and math.isfinite(self.depth_max) and self.depth_min <= self.depth_max):
|
||||
raise ValueError("invalid local depth range")
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Animation:
|
||||
name:str
|
||||
frames_per_direction:int
|
||||
|
||||
|
||||
def _stream(frame: Frame, kind: str) -> bytes:
|
||||
h, w = frame.height, frame.width
|
||||
rows: list[bytes] = []
|
||||
for y in range(h):
|
||||
spans: list[bytes] = []
|
||||
x = 0
|
||||
while x < w:
|
||||
while x < w and frame.indices[y * w + x] == 0:
|
||||
x += 1
|
||||
start = x
|
||||
while x < w and frame.indices[y * w + x] != 0:
|
||||
x += 1
|
||||
if start == x:
|
||||
continue
|
||||
length = x - start
|
||||
if kind == "color":
|
||||
payload = frame.indices[y*w+start:y*w+x]
|
||||
elif kind == "normal":
|
||||
assert frame.normals_xy is not None
|
||||
payload = frame.normals_xy[(y*w+start)*2:(y*w+x)*2]
|
||||
else:
|
||||
assert frame.depth is not None
|
||||
payload = frame.depth[y*w+start:y*w+x]
|
||||
spans.append(struct.pack("<HH", start, length) + payload)
|
||||
rows.append(struct.pack("<H", len(spans)) + b"".join(spans))
|
||||
table_size = 4 * (h + 1)
|
||||
offsets = [table_size]
|
||||
for row in rows:
|
||||
offsets.append(offsets[-1] + len(row))
|
||||
return struct.pack(f"<{h+1}I", *offsets) + b"".join(rows)
|
||||
|
||||
|
||||
def encode_sfa(frames: Sequence[Frame], animation: str = "default", directions: Sequence[int] = (0,), layer_count: int = 1,
|
||||
animations:Sequence[Animation]|None=None,layer_orders:Sequence[Sequence[int]]|None=None) -> bytes:
|
||||
if not frames or not directions or len(frames) % len(directions):
|
||||
raise ValueError("frames must divide evenly across directions")
|
||||
for frame in frames:
|
||||
frame.validate()
|
||||
have_n = all(f.normals_xy is not None for f in frames)
|
||||
have_d = all(f.depth is not None for f in frames)
|
||||
if any((f.normals_xy is not None) != have_n for f in frames) or any((f.depth is not None) != have_d for f in frames):
|
||||
raise ValueError("optional streams must be present for every frame or none")
|
||||
flags = (FLAG_NORMALS if have_n else 0) | (FLAG_DEPTH if have_d else 0)
|
||||
animation_specs=tuple(animations or (Animation(animation,len(frames)//len(directions)),))
|
||||
if not animation_specs or any(not item.name or item.frames_per_direction<1 for item in animation_specs):
|
||||
raise ValueError("animations require a name and at least one frame per direction")
|
||||
if sum(item.frames_per_direction*len(directions) for item in animation_specs)!=len(frames):
|
||||
raise ValueError("animation frame ranges do not cover all frames")
|
||||
strings=bytearray();name_offsets=[]
|
||||
for item in animation_specs:
|
||||
name_offsets.append(len(strings));strings.extend(item.name.encode()+b"\0")
|
||||
anim_off = HEADER.size + len(strings)
|
||||
dir_off = anim_off + ANIM.size*len(animation_specs)
|
||||
frame_off = dir_off + DIRECTION.size * len(directions)
|
||||
layer_off = frame_off + FRAME.size * len(frames)
|
||||
data_off = layer_off + 2 * layer_count * len(frames)
|
||||
payload = bytearray()
|
||||
records = []
|
||||
for f in frames:
|
||||
values: list[int] = []
|
||||
for kind, present in (("color", True), ("normal", have_n), ("depth", have_d)):
|
||||
if present:
|
||||
blob = _stream(f, kind)
|
||||
values.extend((data_off + len(payload), len(blob)))
|
||||
payload.extend(blob)
|
||||
else:
|
||||
values.extend((0, 0))
|
||||
records.append(FRAME.pack(f.width, f.height, f.pivot_x, f.pivot_y, f.duration_ms, flags,
|
||||
f.depth_min, f.depth_max, *values, 0))
|
||||
file_size = data_off + len(payload)
|
||||
header = HEADER.pack(MAGIC, *VERSION, flags, file_size, len(animation_specs), len(directions), len(frames),
|
||||
layer_count, 0, HEADER.size, anim_off, dir_off, frame_off, layer_off,
|
||||
data_off, 0, 0, 0)
|
||||
first=0;animation_records=[]
|
||||
for item,name_offset in zip(animation_specs,name_offsets):
|
||||
animation_records.append(ANIM.pack(fnv1a32(item.name),name_offset,first,item.frames_per_direction,len(directions)))
|
||||
first+=item.frames_per_direction*len(directions)
|
||||
first_fpd=animation_specs[0].frames_per_direction
|
||||
dirs = b"".join(DIRECTION.pack(angle, 0, i*first_fpd) for i, angle in enumerate(directions))
|
||||
orders=tuple(tuple(order) for order in (layer_orders or (tuple(range(layer_count)) for _ in frames)))
|
||||
if len(orders)!=len(frames) or any(sorted(order)!=list(range(layer_count)) for order in orders):
|
||||
raise ValueError("each frame requires a layer permutation")
|
||||
flat=[value for order in orders for value in order]
|
||||
layers = struct.pack(f"<{layer_count*len(frames)}H", *flat)
|
||||
return header + bytes(strings) + b"".join(animation_records) + dirs + b"".join(records) + layers + payload
|
||||
|
||||
|
||||
def write_sfa(path: Path, frames: Sequence[Frame], **kwargs: object) -> None:
|
||||
path.write_bytes(encode_sfa(frames, **kwargs))
|
||||
182
src/spriteforge/introspection.py
Normal file
182
src/spriteforge/introspection.py
Normal file
@ -0,0 +1,182 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image,ImageDraw
|
||||
|
||||
from .manifest import AssetEntry
|
||||
from .palette import PalettePack,decode_sfp
|
||||
from .reader import SfaFile,decode_sfa
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ValidationIssue:
|
||||
severity:str
|
||||
asset_id:str
|
||||
message:str
|
||||
def __str__(self)->str:
|
||||
target=f" {self.asset_id}" if self.asset_id else ""
|
||||
return f"{self.severity.upper()}{target}: {self.message}"
|
||||
|
||||
class Catalog:
|
||||
def __init__(self,build_dir:Path):
|
||||
self.build_dir=build_dir.resolve();index_path=self.build_dir/"index.json"
|
||||
try:self.index=json.loads(index_path.read_text(encoding="utf-8"))
|
||||
except (OSError,json.JSONDecodeError) as error:raise ValueError(f"cannot read catalog {index_path}: {error}") from error
|
||||
if self.index.get("version")!=1 or not isinstance(self.index.get("assets"),list):raise ValueError("unsupported or malformed index.json")
|
||||
self.assets={item["id"]:item for item in self.index["assets"] if isinstance(item,dict) and isinstance(item.get("id"),str)}
|
||||
if len(self.assets)!=len(self.index["assets"]):raise ValueError("duplicate or malformed assets in index.json")
|
||||
|
||||
def asset(self,asset_id:str)->dict[str,Any]:
|
||||
try:return self.assets[asset_id]
|
||||
except KeyError as error:raise ValueError(f"asset not found: {asset_id}") from error
|
||||
|
||||
def _file(self,name:str)->Path:
|
||||
path=(self.build_dir/name).resolve()
|
||||
if path.parent!=self.build_dir:raise ValueError("catalog contains unsafe file path")
|
||||
return path
|
||||
|
||||
def sfa(self,asset_id:str)->SfaFile:
|
||||
item=self.asset(asset_id);path=self._file(item["file"])
|
||||
try:return decode_sfa(path.read_bytes())
|
||||
except (OSError,ValueError) as error:raise ValueError(f"cannot read {asset_id}: {error}") from error
|
||||
|
||||
def palettes(self)->PalettePack:
|
||||
palette=self.index.get("palette",{});name=palette.get("file") if isinstance(palette,dict) else palette
|
||||
if not isinstance(name,str):raise ValueError("catalog has no palette file")
|
||||
try:return decode_sfp(self._file(name).read_bytes())
|
||||
except (OSError,ValueError) as error:raise ValueError(f"cannot read palettes: {error}") from error
|
||||
|
||||
def list_rows(catalog:Catalog,tag:str|None=None,backend:str|None=None)->list[dict[str,Any]]:
|
||||
rows=[]
|
||||
for item in catalog.assets.values():
|
||||
if tag and tag not in item.get("tags",[]):continue
|
||||
if backend and item.get("backend")!=backend:continue
|
||||
sizes=item.get("sizes",[]);unique={tuple(size) for size in sizes}
|
||||
size="mixed" if len(unique)>1 else (f"{sizes[0][0]}x{sizes[0][1]}" if sizes else "-")
|
||||
rows.append({"id":item["id"],"backend":item.get("backend","?"),"frames":item.get("frames",0),
|
||||
"dirs":item.get("directions",0),"size":size,"hash":item.get("hash","-")[:12],"status":item.get("status","?")})
|
||||
return sorted(rows,key=lambda row:row["id"])
|
||||
|
||||
def table(rows:list[dict[str,Any]],columns:tuple[str,...])->str:
|
||||
if not rows:return "(no assets)"
|
||||
widths={column:max(len(column),*(len(str(row.get(column,""))) for row in rows)) for column in columns}
|
||||
header=" ".join(column.upper().ljust(widths[column]) for column in columns)
|
||||
divider=" ".join("-"*widths[column] for column in columns)
|
||||
body=[" ".join(str(row.get(column,"")).ljust(widths[column]) for column in columns) for row in rows]
|
||||
return "\n".join([header,divider,*body])
|
||||
|
||||
def describe_text(catalog:Catalog,asset_id:str)->str:
|
||||
item=catalog.asset(asset_id);lines=[f"id: {asset_id}"]
|
||||
for key in ("backend","backend_version","postprocess_version","status","hash","content_hash","file","bytes","frames","directions","animation","animations","layer_count","layer_orders","warnings","sizes","pivots","durations_ms","tags","source_manifest"):
|
||||
if key in item:lines.append(f"{key}: {json.dumps(item[key],ensure_ascii=False) if isinstance(item[key],(list,dict)) else item[key]}")
|
||||
lines.append("spec:")
|
||||
for key,value in sorted(item.get("spec",{}).items()):lines.append(f" {key}: {json.dumps(value,ensure_ascii=False,sort_keys=True)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def ascii_preview(catalog:Catalog,asset_id:str,frame_index:int=0,columns:int=40)->str:
|
||||
asset=catalog.sfa(asset_id)
|
||||
if not 0<=frame_index<len(asset.frames):raise ValueError(f"frame index out of range: 0..{len(asset.frames)-1}")
|
||||
frame=asset.frames[frame_index];mask=np.frombuffer(frame.indices,dtype=np.uint8).reshape(frame.height,frame.width)!=0
|
||||
scale=max(1,math.ceil(frame.width/columns));out_w=math.ceil(frame.width/scale);out_h=math.ceil(frame.height/scale)
|
||||
lines=[f"{asset_id} frame={frame_index} size={frame.width}x{frame.height} pivot={frame.pivot_x},{frame.pivot_y}"]
|
||||
for by in range(out_h):
|
||||
chars=[]
|
||||
for bx in range(out_w):
|
||||
block=mask[by*scale:min((by+1)*scale,frame.height),bx*scale:min((bx+1)*scale,frame.width)]
|
||||
ratio=float(block.mean()) if block.size else 0
|
||||
chars.append("#" if ratio>.5 else "+" if ratio else " ")
|
||||
lines.append("".join(chars).rstrip())
|
||||
return "\n".join(lines)
|
||||
|
||||
def palette_text(catalog:Catalog,name:str)->str:
|
||||
pack=catalog.palettes()
|
||||
try:rgb=pack.palettes[name]
|
||||
except KeyError as error:raise ValueError(f"palette not found: {name}; available: {', '.join(sorted(pack.palettes))}") from error
|
||||
lines=["INDEX HEX NAME","----- ------- --------"]
|
||||
for index in range(256):
|
||||
r,g,b=rgb[index*3:index*3+3];label="transparent" if index==0 else "-"
|
||||
lines.append(f"{index:5d} #{r:02X}{g:02X}{b:02X} {label}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def stats_text(catalog:Catalog)->str:
|
||||
assets=list(catalog.assets.values());total=sum(int(item.get("bytes",0)) for item in assets)
|
||||
backends:dict[str,int]={}
|
||||
for item in assets:backends[item.get("backend","?")]=backends.get(item.get("backend","?"),0)+1
|
||||
lines=[f"assets: {len(assets)}",f"frames: {sum(int(x.get('frames',0)) for x in assets)}",f"asset_bytes: {total}",
|
||||
f"normal_coverage: {sum(bool(x.get('streams',{}).get('normals')) for x in assets)}/{len(assets)}",
|
||||
f"depth_coverage: {sum(bool(x.get('streams',{}).get('depth')) for x in assets)}/{len(assets)}",
|
||||
f"warnings: {sum(len(x.get('warnings',[])) for x in assets)}","backends:"]
|
||||
lines.extend(f" {name}: {count}" for name,count in sorted(backends.items()))
|
||||
return "\n".join(lines)
|
||||
|
||||
def validate_catalog(catalog:Catalog,manifest_assets:Iterable[AssetEntry]=())->list[ValidationIssue]:
|
||||
issues=[]
|
||||
expected={entry.id for entry in manifest_assets}
|
||||
for missing in sorted(expected-set(catalog.assets)):issues.append(ValidationIssue("error",missing,"declared in manifest but missing from catalog"))
|
||||
for asset_id,item in sorted(catalog.assets.items()):
|
||||
for warning in item.get("warnings",[]):issues.append(ValidationIssue("warning",asset_id,warning))
|
||||
path=catalog._file(item.get("file",""))
|
||||
if not path.is_file():issues.append(ValidationIssue("error",asset_id,"SFA file is missing"));continue
|
||||
blob=path.read_bytes();actual=hashlib.sha256(blob).hexdigest()
|
||||
if actual!=item.get("content_hash"):issues.append(ValidationIssue("error",asset_id,"file hash differs from index.json"))
|
||||
try:sfa=decode_sfa(blob)
|
||||
except ValueError as error:issues.append(ValidationIssue("error",asset_id,f"invalid SFA: {error}"));continue
|
||||
if len(sfa.frames)!=item.get("frames"):issues.append(ValidationIssue("error",asset_id,"frame count differs from index.json"))
|
||||
if item.get("layer_orders") is not None and [list(x) for x in sfa.layer_orders]!=item.get("layer_orders"):
|
||||
issues.append(ValidationIssue("error",asset_id,"layer order differs from index.json"))
|
||||
names=[entry[0] for entry in sfa.animations]
|
||||
indexed_names=[entry.get("name") for entry in item.get("animations",[])]
|
||||
if indexed_names and names!=indexed_names:issues.append(ValidationIssue("error",asset_id,"animation table differs from index.json"))
|
||||
# Paper-doll modules deliberately share the body's ground pivot, so a
|
||||
# small helmet/weapon/vest bbox may be far away from its own pivot.
|
||||
# That is alignment data, not corruption. Standalone sprites retain
|
||||
# the heuristic, summarized once instead of flooding the report.
|
||||
external_pivot="paperdoll_layer" in item.get("tags",[])
|
||||
far_pivots=[]
|
||||
for number,frame in enumerate(sfa.frames):
|
||||
if not any(frame.indices):issues.append(ValidationIssue("error",asset_id,f"frame {number} is empty"))
|
||||
if frame.duration_ms<=0:issues.append(ValidationIssue("error",asset_id,f"frame {number} has zero duration"))
|
||||
if not external_pivot and (abs(frame.pivot_x)>frame.width*4 or abs(frame.pivot_y)>frame.height*4):
|
||||
far_pivots.append(number)
|
||||
if len(frame.indices)!=frame.width*frame.height:issues.append(ValidationIssue("error",asset_id,f"frame {number} pixel count mismatch"))
|
||||
if far_pivots:
|
||||
sample=", ".join(str(number) for number in far_pivots[:8])
|
||||
suffix="..." if len(far_pivots)>8 else ""
|
||||
issues.append(ValidationIssue("warning",asset_id,
|
||||
f"{len(far_pivots)} frame(s) have pivot far outside bbox: {sample}{suffix}"))
|
||||
try:catalog.palettes()
|
||||
except ValueError as error:issues.append(ValidationIssue("error","",str(error)))
|
||||
return issues
|
||||
|
||||
def diff_text(catalog:Catalog,asset_id:str,against:str)->str:
|
||||
current=catalog.asset(asset_id);history=catalog.build_dir/".sfmeta"/asset_id
|
||||
matches=sorted(history.glob(f"{against}*.json")) if history.is_dir() else []
|
||||
if len(matches)!=1:raise ValueError("comparison hash not found or ambiguous")
|
||||
old=json.loads(matches[0].read_text(encoding="utf-8"))
|
||||
keys=("hash","content_hash","backend_version","postprocess_version","frames","directions","sizes","pivots","durations_ms","spec","bytes")
|
||||
changed=[key for key in keys if old.get(key)!=current.get(key)]
|
||||
if not changed:return f"{asset_id}: no changes against {against}"
|
||||
lines=[f"{asset_id}: {len(changed)} field(s) changed against {against}"]
|
||||
for key in changed:lines.append(f"{key}: {json.dumps(old.get(key),ensure_ascii=False,sort_keys=True)} -> {json.dumps(current.get(key),ensure_ascii=False,sort_keys=True)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def contact_sheet(catalog:Catalog,output:Path,asset_ids:list[str]|None=None)->None:
|
||||
ids=asset_ids or sorted(catalog.assets);pack=catalog.palettes();rgb=np.frombuffer(pack.palettes[sorted(pack.palettes)[0]],dtype=np.uint8).reshape(256,3)
|
||||
rendered=[]
|
||||
for asset_id in ids:
|
||||
frame=catalog.sfa(asset_id).frames[0];indices=np.frombuffer(frame.indices,dtype=np.uint8).reshape(frame.height,frame.width)
|
||||
rgba=np.zeros((frame.height,frame.width,4),dtype=np.uint8);rgba[...,:3]=rgb[indices];rgba[...,3]=np.where(indices!=0,255,0)
|
||||
rendered.append((asset_id,Image.fromarray(rgba,"RGBA")))
|
||||
if not rendered:raise ValueError("no assets for contact sheet")
|
||||
cell_w=max(image.width for _,image in rendered)+16;cell_h=max(image.height for _,image in rendered)+32
|
||||
cols=math.ceil(math.sqrt(len(rendered)));rows=math.ceil(len(rendered)/cols)
|
||||
sheet=Image.new("RGBA",(cell_w*cols,cell_h*rows),(24,24,24,255));draw=ImageDraw.Draw(sheet)
|
||||
for index,(name,image) in enumerate(rendered):
|
||||
x=(index%cols)*cell_w+8;y=(index//cols)*cell_h+20;sheet.alpha_composite(image,(x,y));draw.text((x,4+(index//cols)*cell_h),name,fill=(240,240,240,255))
|
||||
output.parent.mkdir(parents=True,exist_ok=True);sheet.save(output,"PNG")
|
||||
241
src/spriteforge/manifest.py
Normal file
241
src/spriteforge/manifest.py
Normal file
@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
|
||||
import yaml
|
||||
from yaml.nodes import MappingNode, Node, SequenceNode
|
||||
|
||||
BackendName = str
|
||||
PivotName = Literal["bottom_center", "center", "bottom_left", "top_left"]
|
||||
ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
||||
SIZE_RE = re.compile(r"^[1-9][0-9]{0,4}x[1-9][0-9]{0,4}$")
|
||||
|
||||
|
||||
class Defaults(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
backend: BackendName | None = None
|
||||
dirs: int = Field(default=1, ge=1, le=32)
|
||||
fps: float = Field(default=12.0, gt=0, le=1000)
|
||||
pivot: PivotName | str = "bottom_center"
|
||||
size: str | None = None
|
||||
palette: str | None = None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
seed: int = 0
|
||||
postprocess_scale: float | None = Field(default=None, gt=0.0, le=1.0)
|
||||
|
||||
@field_validator("size")
|
||||
@classmethod
|
||||
def valid_size(cls, value: str | None) -> str | None:
|
||||
if value is not None and not SIZE_RE.fullmatch(value):
|
||||
raise ValueError("must use WIDTHxHEIGHT, for example 32x32")
|
||||
return value
|
||||
|
||||
@field_validator("pivot")
|
||||
@classmethod
|
||||
def valid_pivot(cls, value: str) -> str:
|
||||
if value not in {"bottom_center", "center", "bottom_left", "top_left"}:
|
||||
try:
|
||||
x, y = value.split(",")
|
||||
int(x); int(y)
|
||||
except (ValueError, TypeError):
|
||||
raise ValueError("must be a named pivot or signed 'x,y'") from None
|
||||
return value
|
||||
|
||||
|
||||
class AssetSpec(Defaults):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
backend: BackendName
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
generator: str | None = None
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
rig: str | None = None
|
||||
parts: dict[str, str] = Field(default_factory=dict)
|
||||
anims: list[str] = Field(default_factory=list)
|
||||
prompt: str | None = None
|
||||
source: str | None = None
|
||||
layers: list[str] = Field(default_factory=list)
|
||||
palettes: list[str] = Field(default_factory=list)
|
||||
|
||||
@field_validator("tags", "anims", "palettes", "layers")
|
||||
@classmethod
|
||||
def unique_names(cls, values: list[str]) -> list[str]:
|
||||
if any(not value for value in values):
|
||||
raise ValueError("entries must be non-empty strings")
|
||||
if len(set(values)) != len(values):
|
||||
raise ValueError("entries must be unique")
|
||||
return values
|
||||
|
||||
@field_validator("backend")
|
||||
@classmethod
|
||||
def valid_backend_name(cls, value: str) -> str:
|
||||
if not ID_RE.fullmatch(value):
|
||||
raise ValueError("backend name must match [a-z][a-z0-9_]*")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def backend_contract(self) -> "AssetSpec":
|
||||
from .backend_registry import backend_names, get_backend_contract
|
||||
contract = get_backend_contract(self.backend)
|
||||
if contract is None:
|
||||
raise ValueError(f"unknown backend '{self.backend}'; registered: {', '.join(backend_names())}")
|
||||
message = contract.validate(self)
|
||||
if message:
|
||||
raise ValueError(message)
|
||||
return self
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManifestError:
|
||||
path: Path
|
||||
line: int
|
||||
column: int
|
||||
location: str
|
||||
message: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
suffix = f" [{self.location}]" if self.location else ""
|
||||
return f"{self.path}:{self.line}:{self.column}: {self.message}{suffix}"
|
||||
|
||||
|
||||
class ManifestInvalid(Exception):
|
||||
def __init__(self, errors: list[ManifestError]):
|
||||
self.errors = errors
|
||||
super().__init__("\n".join(map(str, errors)))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssetEntry:
|
||||
id: str
|
||||
spec: AssetSpec
|
||||
manifest: Path
|
||||
line: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Manifest:
|
||||
path: Path
|
||||
defaults: Defaults
|
||||
assets: tuple[AssetEntry, ...]
|
||||
|
||||
|
||||
def _marks(node: Node, path: tuple[str, ...] = (), output: dict[tuple[str, ...], tuple[int, int]] | None = None) -> dict[tuple[str, ...], tuple[int, int]]:
|
||||
result = output if output is not None else {}
|
||||
result.setdefault(path, (node.start_mark.line + 1, node.start_mark.column + 1))
|
||||
if isinstance(node, MappingNode):
|
||||
for key, value in node.value:
|
||||
key_text = str(getattr(key, "value", "?"))
|
||||
result[path + (key_text,)] = (key.start_mark.line + 1, key.start_mark.column + 1)
|
||||
_marks(value, path + (key_text,), result)
|
||||
elif isinstance(node, SequenceNode):
|
||||
for index, value in enumerate(node.value):
|
||||
_marks(value, path + (str(index),), result)
|
||||
return result
|
||||
|
||||
|
||||
def _position(marks: dict[tuple[str, ...], tuple[int, int]], location: tuple[str, ...]) -> tuple[int, int]:
|
||||
current = location
|
||||
while current:
|
||||
if current in marks:
|
||||
return marks[current]
|
||||
current = current[:-1]
|
||||
return marks.get((), (1, 1))
|
||||
|
||||
|
||||
def _validation_errors(path: Path, error: ValidationError, marks: dict[tuple[str, ...], tuple[int, int]], prefix: tuple[str, ...]) -> list[ManifestError]:
|
||||
output = []
|
||||
for item in error.errors(include_url=False):
|
||||
loc = prefix + tuple(str(part) for part in item["loc"])
|
||||
line, column = _position(marks, loc)
|
||||
message = item["msg"].removeprefix("Value error, ")
|
||||
output.append(ManifestError(path, line, column, ".".join(loc), message))
|
||||
return output
|
||||
|
||||
|
||||
def load_manifest(path: str | Path) -> Manifest:
|
||||
source = Path(path).resolve()
|
||||
try:
|
||||
text = source.read_text(encoding="utf-8")
|
||||
except OSError as error:
|
||||
raise ManifestInvalid([ManifestError(source, 1, 1, "", str(error))]) from error
|
||||
try:
|
||||
root_node = yaml.compose(text)
|
||||
raw = yaml.safe_load(text)
|
||||
except yaml.MarkedYAMLError as error:
|
||||
mark = error.problem_mark
|
||||
raise ManifestInvalid([ManifestError(source, (mark.line + 1) if mark else 1,
|
||||
(mark.column + 1) if mark else 1, "", error.problem or str(error))]) from error
|
||||
if root_node is None or not isinstance(raw, dict):
|
||||
raise ManifestInvalid([ManifestError(source, 1, 1, "", "manifest root must be a mapping")])
|
||||
marks = _marks(root_node)
|
||||
errors: list[ManifestError] = []
|
||||
if raw.get("version", 1) != 1:
|
||||
line, column = _position(marks, ("version",))
|
||||
errors.append(ManifestError(source, line, column, "version", "only manifest version 1 is supported"))
|
||||
defaults_raw = raw.get("defaults", {})
|
||||
try:
|
||||
defaults = Defaults.model_validate(defaults_raw)
|
||||
except ValidationError as error:
|
||||
errors.extend(_validation_errors(source, error, marks, ("defaults",)))
|
||||
defaults = Defaults()
|
||||
if "assets" in raw:
|
||||
asset_raw = raw.get("assets")
|
||||
unknown = set(raw) - {"version", "defaults", "assets"}
|
||||
if unknown:
|
||||
for key in sorted(unknown):
|
||||
line, col = _position(marks, (key,))
|
||||
errors.append(ManifestError(source, line, col, key, "cannot mix flat assets with an 'assets' section"))
|
||||
prefix = ("assets",)
|
||||
else:
|
||||
asset_raw = {key: value for key, value in raw.items() if key not in {"version", "defaults"}}
|
||||
prefix = ()
|
||||
if not isinstance(asset_raw, dict):
|
||||
line, col = _position(marks, prefix)
|
||||
errors.append(ManifestError(source, line, col, ".".join(prefix), "assets must be a mapping"))
|
||||
asset_raw = {}
|
||||
entries = []
|
||||
inherited = defaults.model_dump(exclude_none=True)
|
||||
for asset_id, values in asset_raw.items():
|
||||
asset_path = prefix + (str(asset_id),)
|
||||
line, col = _position(marks, asset_path)
|
||||
if not isinstance(asset_id, str) or not ID_RE.fullmatch(asset_id):
|
||||
errors.append(ManifestError(source, line, col, ".".join(asset_path), "asset id must match [a-z][a-z0-9_]*"))
|
||||
continue
|
||||
if not isinstance(values, dict):
|
||||
errors.append(ManifestError(source, line, col, ".".join(asset_path), "asset specification must be a mapping"))
|
||||
continue
|
||||
merged = {**inherited, **values}
|
||||
try:
|
||||
spec = AssetSpec.model_validate(merged)
|
||||
entries.append(AssetEntry(asset_id, spec, source, line))
|
||||
except ValidationError as error:
|
||||
errors.extend(_validation_errors(source, error, marks, asset_path))
|
||||
if errors:
|
||||
raise ManifestInvalid(errors)
|
||||
return Manifest(source, defaults, tuple(entries))
|
||||
|
||||
|
||||
def load_library(paths: list[str | Path]) -> tuple[AssetEntry, ...]:
|
||||
entries: list[AssetEntry] = []
|
||||
seen: dict[str, AssetEntry] = {}
|
||||
errors: list[ManifestError] = []
|
||||
for path in paths:
|
||||
try:
|
||||
manifest = load_manifest(path)
|
||||
except ManifestInvalid as error:
|
||||
errors.extend(error.errors)
|
||||
continue
|
||||
for entry in manifest.assets:
|
||||
previous = seen.get(entry.id)
|
||||
if previous:
|
||||
errors.append(ManifestError(entry.manifest, entry.line, 1, entry.id,
|
||||
f"duplicate asset id; first declared at {previous.manifest}:{previous.line}"))
|
||||
else:
|
||||
seen[entry.id] = entry
|
||||
entries.append(entry)
|
||||
if errors:
|
||||
raise ManifestInvalid(errors)
|
||||
return tuple(entries)
|
||||
64
src/spriteforge/palette.py
Normal file
64
src/spriteforge/palette.py
Normal file
@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from typing import Mapping, Sequence
|
||||
from .format import fnv1a32
|
||||
|
||||
MAGIC=b"SFP\0"
|
||||
HEADER=struct.Struct("<4sHHIIIIIIII")
|
||||
RECORD=struct.Struct("<IIIII")
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PalettePack:
|
||||
palettes: dict[str,bytes]
|
||||
colormaps: dict[str,bytes]
|
||||
|
||||
def encode_sfp(palettes: Mapping[str,Sequence[tuple[int,int,int]]],
|
||||
colormaps: Mapping[str,Sequence[int]]|None=None)->bytes:
|
||||
maps=colormaps or {}
|
||||
if not palettes: raise ValueError("at least one palette is required")
|
||||
all_names=list(palettes)+list(maps)
|
||||
if len(set(all_names))!=len(all_names): raise ValueError("palette and colormap names must be unique")
|
||||
strings=bytearray(); name_offsets={}
|
||||
for name in all_names:
|
||||
if not name or "\0" in name: raise ValueError("invalid name")
|
||||
name_offsets[name]=len(strings); strings.extend(name.encode()+b"\0")
|
||||
pal_records=HEADER.size+len(strings)
|
||||
map_records=pal_records+RECORD.size*len(palettes)
|
||||
data_off=map_records+RECORD.size*len(maps)
|
||||
records=bytearray(); payload=bytearray()
|
||||
for name,entries in palettes.items():
|
||||
if len(entries)!=256: raise ValueError(f"palette {name!r} must contain 256 RGB entries")
|
||||
off=data_off+len(payload)
|
||||
for rgb in entries:
|
||||
if len(rgb)!=3 or any(not 0<=c<=255 for c in rgb): raise ValueError("RGB components must be bytes")
|
||||
payload.extend(bytes(rgb))
|
||||
records.extend(RECORD.pack(fnv1a32(name),name_offsets[name],off,0,0))
|
||||
for name,mapping in maps.items():
|
||||
if len(mapping)!=256 or any(not 0<=i<=255 for i in mapping): raise ValueError("colormap must contain 256 byte indices")
|
||||
off=data_off+len(payload); payload.extend(bytes(mapping))
|
||||
records.extend(RECORD.pack(fnv1a32(name),name_offsets[name],off,0,0))
|
||||
size=data_off+len(payload)
|
||||
return HEADER.pack(MAGIC,1,0,0,size,len(palettes),len(maps),HEADER.size,pal_records,map_records,data_off)+strings+records+payload
|
||||
|
||||
def decode_sfp(data:bytes)->PalettePack:
|
||||
if len(data)<HEADER.size: raise ValueError("truncated SFP")
|
||||
magic,major,minor,flags,size,pc,mc,strings,pro,mro,_payload=HEADER.unpack_from(data)
|
||||
if magic!=MAGIC or major!=1 or minor>0 or flags or size!=len(data): raise ValueError("invalid SFP header")
|
||||
def read_records(count:int,off:int,item_size:int)->dict[str,bytes]:
|
||||
result={}
|
||||
if off>len(data) or RECORD.size*count>len(data)-off: raise ValueError("record table outside file")
|
||||
for i in range(count):
|
||||
h,nrel,payload,r0,r1=RECORD.unpack_from(data,off+i*RECORD.size)
|
||||
start=strings+nrel; end=data.find(b"\0",start,pro)
|
||||
if end<0 or r0 or r1 or payload>len(data) or item_size>len(data)-payload: raise ValueError("invalid record")
|
||||
name=data[start:end].decode()
|
||||
if fnv1a32(name)!=h or name in result: raise ValueError("invalid name hash")
|
||||
result[name]=data[payload:payload+item_size]
|
||||
return result
|
||||
return PalettePack(read_records(pc,pro,768),read_records(mc,mro,256))
|
||||
|
||||
def write_sfp(path:Path,palettes:Mapping[str,Sequence[tuple[int,int,int]]],
|
||||
colormaps:Mapping[str,Sequence[int]]|None=None)->None:
|
||||
path.write_bytes(encode_sfp(palettes,colormaps))
|
||||
332
src/spriteforge/pixel.py
Normal file
332
src/spriteforge/pixel.py
Normal file
@ -0,0 +1,332 @@
|
||||
"""Переиспользуемый набор примитивов пиксель-арта поверх numpy RGBA.
|
||||
|
||||
Модуль ничего не знает ни о манифестах, ни о бэкендах: на входе и выходе —
|
||||
массив HxWx4 uint8, тот же, что ждёт RawFrame. Случайность только явная:
|
||||
любой шум берёт переданный numpy Generator, глобального random здесь нет,
|
||||
иначе сборка перестала бы быть детерминированной.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
RgbaArray = NDArray[np.uint8]
|
||||
Mask = NDArray[np.bool_]
|
||||
ColorLike = str | Sequence[int] | NDArray[np.uint8]
|
||||
|
||||
# Тот же порог, по которому postprocess.normalize_frame режет силуэт: рисовать
|
||||
# кромку прозрачнее бессмысленно — она всё равно пропадёт при нормализации.
|
||||
ALPHA_THRESHOLD = 128
|
||||
SHADOW_FACTOR = 0.62
|
||||
HIGHLIGHT_FACTOR = 1.32
|
||||
FAR_LIMB_FACTOR = 0.62
|
||||
RIM_BRIGHTEN = 1.6
|
||||
|
||||
|
||||
def to_rgba(color: ColorLike) -> NDArray[np.uint8]:
|
||||
"""'#RRGGBB', '#RRGGBBAA' или (r,g,b[,a]) в uint8[4]."""
|
||||
if isinstance(color, str):
|
||||
text = color.strip()
|
||||
if not text.startswith("#") or len(text) not in (7, 9):
|
||||
raise ValueError(f"color '{color}' must be #RRGGBB or #RRGGBBAA")
|
||||
try:
|
||||
channels = [int(text[i:i + 2], 16) for i in range(1, len(text), 2)]
|
||||
except ValueError as error:
|
||||
raise ValueError(f"color '{color}' contains invalid hex") from error
|
||||
else:
|
||||
values = np.asarray(color).reshape(-1)
|
||||
if values.size not in (3, 4):
|
||||
raise ValueError("color sequence must have 3 or 4 channels")
|
||||
channels = [int(value) for value in values]
|
||||
if any(not 0 <= value <= 255 for value in channels):
|
||||
raise ValueError("color channels must be in 0..255")
|
||||
if len(channels) == 3:
|
||||
channels.append(255)
|
||||
return np.asarray(channels, dtype=np.uint8)
|
||||
|
||||
|
||||
def canvas(width: int, height: int, color: ColorLike | None = None) -> RgbaArray:
|
||||
"""Пустой (прозрачный) холст либо залитый заданным цветом."""
|
||||
if width < 1 or height < 1:
|
||||
raise ValueError("canvas size must be positive")
|
||||
image = np.zeros((height, width, 4), dtype=np.uint8)
|
||||
if color is not None:
|
||||
image[:, :] = to_rgba(color)
|
||||
return image
|
||||
|
||||
|
||||
def _put(image: RgbaArray, selector, color: NDArray[np.uint8], blend: bool) -> None:
|
||||
"""Кладёт цвет по маске, срезу или индексам; полупрозрачный — source-over.
|
||||
|
||||
Выборка приводится к плоскому списку пикселей: маска, срез и пара индексных
|
||||
массивов дают разную форму, а смешивание должно работать одинаково для всех.
|
||||
"""
|
||||
alpha = int(color[3])
|
||||
if blend and alpha == 0:
|
||||
return
|
||||
if not blend or alpha == 255:
|
||||
image[selector] = color
|
||||
return
|
||||
region = image[selector]
|
||||
if not region.size:
|
||||
return
|
||||
destination = region.reshape(-1, 4).astype(np.float32)
|
||||
source_alpha = alpha / 255.0
|
||||
destination_alpha = destination[:, 3:4] / 255.0
|
||||
out_alpha = source_alpha + destination_alpha * (1.0 - source_alpha)
|
||||
rgb = (color[:3].astype(np.float32) * source_alpha
|
||||
+ destination[:, :3] * destination_alpha * (1.0 - source_alpha)) / out_alpha
|
||||
merged = np.concatenate((rgb, out_alpha * 255.0), axis=1)
|
||||
image[selector] = np.clip(np.rint(merged), 0, 255).astype(np.uint8).reshape(region.shape)
|
||||
|
||||
|
||||
def fill(image: RgbaArray, color: ColorLike, mask: Mask | None = None, *, blend: bool = False) -> None:
|
||||
"""Заливка всего холста или его части; по умолчанию перезаписывает пиксели."""
|
||||
selector = slice(None) if mask is None else mask
|
||||
_put(image, selector, to_rgba(color), blend)
|
||||
|
||||
|
||||
def set_pixel(image: RgbaArray, x: int, y: int, color: ColorLike, *, blend: bool = True) -> None:
|
||||
"""Пиксель с проверкой границ: промах за холст молча игнорируется."""
|
||||
height, width = image.shape[:2]
|
||||
if not (0 <= x < width and 0 <= y < height):
|
||||
return
|
||||
_put(image, (np.asarray([int(y)]), np.asarray([int(x)])), to_rgba(color), blend)
|
||||
|
||||
|
||||
def line_points(x0: int, y0: int, x1: int, y1: int) -> tuple[tuple[int, int], ...]:
|
||||
"""Брезенхем без сглаживания: пиксель-арт не терпит полупрозрачных краёв."""
|
||||
x0, y0, x1, y1 = int(x0), int(y0), int(x1), int(y1)
|
||||
dx = abs(x1 - x0)
|
||||
dy = -abs(y1 - y0)
|
||||
step_x = 1 if x0 < x1 else -1
|
||||
step_y = 1 if y0 < y1 else -1
|
||||
error = dx + dy
|
||||
points = []
|
||||
while True:
|
||||
points.append((x0, y0))
|
||||
if x0 == x1 and y0 == y1:
|
||||
break
|
||||
doubled = 2 * error
|
||||
if doubled >= dy:
|
||||
error += dy
|
||||
x0 += step_x
|
||||
if doubled <= dx:
|
||||
error += dx
|
||||
y0 += step_y
|
||||
return tuple(points)
|
||||
|
||||
|
||||
def draw_line(image: RgbaArray, x0: float, y0: float, x1: float, y1: float, color: ColorLike, *,
|
||||
thickness: float = 1.0, blend: bool = True) -> None:
|
||||
"""Линия; при толщине больше пикселя это капсула с круглыми концами."""
|
||||
height, width = image.shape[:2]
|
||||
if thickness > 1:
|
||||
_put(image, capsule_mask(width, height, x0, y0, x1, y1, thickness), to_rgba(color), blend)
|
||||
return
|
||||
points = [(x, y) for x, y in line_points(round(x0), round(y0), round(x1), round(y1))
|
||||
if 0 <= x < width and 0 <= y < height]
|
||||
if not points:
|
||||
return
|
||||
rows = np.asarray([point[1] for point in points])
|
||||
columns = np.asarray([point[0] for point in points])
|
||||
_put(image, (rows, columns), to_rgba(color), blend)
|
||||
|
||||
|
||||
def draw_rect(image: RgbaArray, x0: int, y0: int, x1: int, y1: int, color: ColorLike, *,
|
||||
filled: bool = True, thickness: int = 1, blend: bool = True) -> None:
|
||||
"""Прямоугольник по включительным углам; рамка растёт внутрь."""
|
||||
height, width = image.shape[:2]
|
||||
left, right = sorted((int(x0), int(x1)))
|
||||
top, bottom = sorted((int(y0), int(y1)))
|
||||
mask = np.zeros((height, width), dtype=bool)
|
||||
clipped = _clip_box(left, top, right, bottom, width, height)
|
||||
if clipped is None:
|
||||
return
|
||||
mask[clipped[1]:clipped[3] + 1, clipped[0]:clipped[2] + 1] = True
|
||||
if not filled:
|
||||
thickness = max(1, int(thickness))
|
||||
inner = _clip_box(left + thickness, top + thickness, right - thickness, bottom - thickness, width, height)
|
||||
if inner is not None:
|
||||
mask[inner[1]:inner[3] + 1, inner[0]:inner[2] + 1] = False
|
||||
_put(image, mask, to_rgba(color), blend)
|
||||
|
||||
|
||||
def _clip_box(left: int, top: int, right: int, bottom: int, width: int, height: int) -> tuple[int, int, int, int] | None:
|
||||
if left > right or top > bottom:
|
||||
return None
|
||||
left, top = max(0, left), max(0, top)
|
||||
right, bottom = min(width - 1, right), min(height - 1, bottom)
|
||||
return None if left > right or top > bottom else (left, top, right, bottom)
|
||||
|
||||
|
||||
def ellipse_mask(width: int, height: int, cx: float, cy: float, rx: float, ry: float) -> Mask:
|
||||
"""Залитый эллипс: центр в координатах пикселя, радиусы в пикселях."""
|
||||
if rx <= 0 or ry <= 0:
|
||||
return np.zeros((height, width), dtype=bool)
|
||||
rows, columns = np.mgrid[0:height, 0:width]
|
||||
nx = (columns - float(cx)) / float(rx)
|
||||
ny = (rows - float(cy)) / float(ry)
|
||||
return nx * nx + ny * ny <= 1.0
|
||||
|
||||
|
||||
def draw_ellipse(image: RgbaArray, cx: float, cy: float, rx: float, ry: float, color: ColorLike, *,
|
||||
filled: bool = True, thickness: int = 1, blend: bool = True) -> None:
|
||||
"""Эллипс/овал, залитый или контурный (контур растёт внутрь)."""
|
||||
height, width = image.shape[:2]
|
||||
mask = ellipse_mask(width, height, cx, cy, rx, ry)
|
||||
if not filled:
|
||||
thickness = max(1, int(thickness))
|
||||
mask &= ~ellipse_mask(width, height, cx, cy, rx - thickness, ry - thickness)
|
||||
_put(image, mask, to_rgba(color), blend)
|
||||
|
||||
|
||||
def capsule_mask(width: int, height: int, x0: float, y0: float, x1: float, y1: float, thickness: float) -> Mask:
|
||||
"""Все пиксели ближе радиуса к отрезку — конечность одной толщины."""
|
||||
radius = max(float(thickness), 1.0) / 2.0
|
||||
rows, columns = np.mgrid[0:height, 0:width]
|
||||
dx, dy = float(x1) - float(x0), float(y1) - float(y0)
|
||||
length = dx * dx + dy * dy
|
||||
if length == 0.0:
|
||||
projection = np.zeros((height, width), dtype=np.float64)
|
||||
else:
|
||||
projection = np.clip(((columns - float(x0)) * dx + (rows - float(y0)) * dy) / length, 0.0, 1.0)
|
||||
nearest_x = float(x0) + projection * dx
|
||||
nearest_y = float(y0) + projection * dy
|
||||
return (columns - nearest_x) ** 2 + (rows - nearest_y) ** 2 <= radius * radius
|
||||
|
||||
|
||||
def draw_capsule(image: RgbaArray, x0: float, y0: float, x1: float, y1: float, thickness: float,
|
||||
color: ColorLike, *, blend: bool = True) -> None:
|
||||
"""Капсула между двумя точками — базовый кирпич рук, ног и туловища."""
|
||||
height, width = image.shape[:2]
|
||||
_put(image, capsule_mask(width, height, x0, y0, x1, y1, thickness), to_rgba(color), blend)
|
||||
|
||||
|
||||
def opaque_mask(image: RgbaArray, alpha_threshold: int = ALPHA_THRESHOLD) -> Mask:
|
||||
return image[..., 3] >= alpha_threshold
|
||||
|
||||
|
||||
def edge_mask(image: RgbaArray, *, alpha_threshold: int = ALPHA_THRESHOLD) -> Mask:
|
||||
"""Кромка силуэта: непрозрачный пиксель, у которого есть прозрачный 4-сосед.
|
||||
|
||||
За краем холста считается прозрачность, иначе обрезанный по границе силуэт
|
||||
остался бы без обводки с этой стороны.
|
||||
"""
|
||||
solid = opaque_mask(image, alpha_threshold)
|
||||
height, width = solid.shape
|
||||
padded = np.zeros((height + 2, width + 2), dtype=bool)
|
||||
padded[1:-1, 1:-1] = solid
|
||||
surrounded = padded[:-2, 1:-1] & padded[2:, 1:-1] & padded[1:-1, :-2] & padded[1:-1, 2:]
|
||||
return solid & ~surrounded
|
||||
|
||||
|
||||
def outline(image: RgbaArray, color: ColorLike, *, alpha_threshold: int = ALPHA_THRESHOLD,
|
||||
blend: bool = True) -> None:
|
||||
"""Обводка силуэта изнутри: читаемость фигуры на тёмном фоне."""
|
||||
_put(image, edge_mask(image, alpha_threshold=alpha_threshold), to_rgba(color), blend)
|
||||
|
||||
|
||||
def shade(color: ColorLike, factor: float) -> NDArray[np.uint8]:
|
||||
"""Затенение/осветление цвета множителем; альфа не трогается."""
|
||||
rgba = to_rgba(color).astype(np.float32)
|
||||
rgba[:3] = np.clip(rgba[:3] * float(factor), 0.0, 255.0)
|
||||
return np.clip(np.rint(rgba), 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def ramp(color: ColorLike, *, shadow: float = SHADOW_FACTOR,
|
||||
highlight: float = HIGHLIGHT_FACTOR) -> tuple[NDArray[np.uint8], NDArray[np.uint8], NDArray[np.uint8]]:
|
||||
"""Тень, база, блик — трёхступенчатая рампа, которой хватает пиксель-арту."""
|
||||
return shade(color, shadow), to_rgba(color), shade(color, highlight)
|
||||
|
||||
|
||||
def depth_factor(depth: float, *, far: float = FAR_LIMB_FACTOR, near: float = 1.0) -> float:
|
||||
"""Множитель яркости по глубине: 0 — самое дальнее, 1 — самое ближнее."""
|
||||
return far + (near - far) * float(np.clip(depth, 0.0, 1.0))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Limb:
|
||||
"""Конечность как отрезок с толщиной; depth задаёт порядок и затенение."""
|
||||
x0: float
|
||||
y0: float
|
||||
x1: float
|
||||
y1: float
|
||||
thickness: float
|
||||
color: ColorLike
|
||||
depth: float = 1.0
|
||||
|
||||
|
||||
def sort_by_depth(limbs: Sequence[Limb]) -> tuple[Limb, ...]:
|
||||
"""Дальние вперёд: их рисуем раньше, чтобы ближние легли поверх.
|
||||
|
||||
Сортировка стабильная, поэтому одинаковые глубины не меняют порядок между
|
||||
запусками — иначе кадр перестал бы быть воспроизводимым.
|
||||
"""
|
||||
return tuple(sorted(limbs, key=lambda limb: limb.depth))
|
||||
|
||||
|
||||
def draw_limbs(image: RgbaArray, limbs: Sequence[Limb], *, far: float = FAR_LIMB_FACTOR, near: float = 1.0,
|
||||
blend: bool = True) -> None:
|
||||
"""Рисует конечности от дальних к ближним, притемняя дальние."""
|
||||
for limb in sort_by_depth(limbs):
|
||||
color = shade(limb.color, depth_factor(limb.depth, far=far, near=near))
|
||||
draw_capsule(image, limb.x0, limb.y0, limb.x1, limb.y1, limb.thickness, color, blend=blend)
|
||||
|
||||
|
||||
def rim_light(image: RgbaArray, direction: tuple[float, float], color: ColorLike | None = None, *,
|
||||
strength: float = 0.5, threshold: float = 0.3,
|
||||
alpha_threshold: int = ALPHA_THRESHOLD) -> None:
|
||||
"""Подсвечивает кромку со стороны вектора direction (x вправо, y вниз).
|
||||
|
||||
Вектор указывает НА источник: подсвечиваются те пиксели кромки, чья внешняя
|
||||
нормаль смотрит в его сторону. Без цвета кромка просто высветляется от
|
||||
собственного цвета — так рим не тащит в палитру лишних оттенков.
|
||||
"""
|
||||
height, width = image.shape[:2]
|
||||
if height < 2 or width < 2:
|
||||
return
|
||||
dx, dy = float(direction[0]), float(direction[1])
|
||||
norm = math.hypot(dx, dy)
|
||||
if norm == 0.0:
|
||||
raise ValueError("rim light direction must be a non-zero vector")
|
||||
dx, dy = dx / norm, dy / norm
|
||||
solid = opaque_mask(image, alpha_threshold)
|
||||
gradient_y, gradient_x = np.gradient(solid.astype(np.float32))
|
||||
normal_x, normal_y = -gradient_x, -gradient_y
|
||||
length = np.hypot(normal_x, normal_y)
|
||||
facing = np.where(length > 0.0, (normal_x * dx + normal_y * dy) / np.where(length > 0.0, length, 1.0), -1.0)
|
||||
lit = solid & (facing >= threshold)
|
||||
if not lit.any():
|
||||
return
|
||||
weight = (np.clip((facing[lit] - threshold) / max(1e-6, 1.0 - threshold), 0.0, 1.0) * float(strength))[:, None]
|
||||
base = image[lit, :3].astype(np.float32)
|
||||
target = (np.broadcast_to(to_rgba(color)[:3].astype(np.float32), base.shape) if color is not None
|
||||
else np.clip(base * RIM_BRIGHTEN, 0.0, 255.0))
|
||||
image[lit, :3] = np.clip(np.rint(base + (target - base) * weight), 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def add_noise(image: RgbaArray, rng: np.random.Generator, amount: int = 8, *, mask: Mask | None = None) -> None:
|
||||
"""Зернистость по непрозрачным пикселям; поток случайных чисел — только из rng."""
|
||||
amount = int(amount)
|
||||
if amount <= 0:
|
||||
return
|
||||
target = opaque_mask(image, 1) if mask is None else mask
|
||||
jitter = rng.integers(-amount, amount + 1, size=image.shape[:2] + (1,), dtype=np.int16)
|
||||
rgb = image[..., :3].astype(np.int16)
|
||||
image[..., :3] = np.where(target[..., None], np.clip(rgb + jitter, 0, 255), rgb).astype(np.uint8)
|
||||
|
||||
|
||||
def flip_horizontal(image: RgbaArray) -> RgbaArray:
|
||||
"""Зеркало по X: половину направлений персонажа обычно берут отражением."""
|
||||
return np.ascontiguousarray(image[:, ::-1])
|
||||
|
||||
|
||||
def flip_pivot_x(pivot_x: int, width: int) -> int:
|
||||
"""Пивот после flip_horizontal: без этого зеркальный кадр съезжает на пиксель."""
|
||||
return width - 1 - int(pivot_x)
|
||||
105
src/spriteforge/postprocess.py
Normal file
105
src/spriteforge/postprocess.py
Normal file
@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import math
|
||||
from typing import Iterable
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from .backends.base import RawAsset, RawFrame
|
||||
from .format import Frame
|
||||
|
||||
BAYER4=np.asarray([[0,8,2,10],[12,4,14,6],[3,11,1,9],[15,7,13,5]],dtype=np.float32)-7.5
|
||||
POSTPROCESS_VERSION="1.1.0"
|
||||
|
||||
def _target_size(value: str | None) -> tuple[int,int] | None:
|
||||
if value is None:return None
|
||||
w,h=value.split("x");return int(w),int(h)
|
||||
|
||||
def normalize_frame(frame: RawFrame,target: tuple[int,int]|None=None,alpha_threshold: int=128,
|
||||
source_scale:float=1.0)->RawFrame:
|
||||
rgba=frame.rgba.copy(); mask=rgba[...,3]>=alpha_threshold
|
||||
if not mask.any(): raise ValueError("generated frame is empty after alpha threshold")
|
||||
rgba[...,3]=np.where(mask,255,0).astype(np.uint8);rgba[~mask,:3]=0
|
||||
ys,xs=np.nonzero(mask);left,right=int(xs.min()),int(xs.max())+1;top,bottom=int(ys.min()),int(ys.max())+1
|
||||
rgba=rgba[top:bottom,left:right]
|
||||
normals=frame.normals_xy[top:bottom,left:right].copy() if frame.normals_xy is not None else None
|
||||
depth=frame.depth[top:bottom,left:right].copy() if frame.depth is not None else None
|
||||
px,py=frame.pivot_x-left,frame.pivot_y-top
|
||||
def resize(scale:float)->None:
|
||||
nonlocal rgba,normals,depth,px,py
|
||||
nw=max(1,round(rgba.shape[1]*scale));nh=max(1,round(rgba.shape[0]*scale))
|
||||
rgba=np.asarray(Image.fromarray(rgba,"RGBA").resize((nw,nh),Image.Resampling.NEAREST))
|
||||
if normals is not None:normals=np.asarray(Image.fromarray(normals,"LA").resize((nw,nh),Image.Resampling.NEAREST))
|
||||
if depth is not None:depth=np.asarray(Image.fromarray(depth,"L").resize((nw,nh),Image.Resampling.NEAREST))
|
||||
px=round(px*scale);py=round(py*scale)
|
||||
if source_scale!=1.0:resize(source_scale)
|
||||
if target is not None and (rgba.shape[1]>target[0] or rgba.shape[0]>target[1]):
|
||||
resize(min(target[0]/rgba.shape[1],target[1]/rgba.shape[0]))
|
||||
return replace(frame,rgba=np.ascontiguousarray(rgba),pivot_x=px,pivot_y=py,
|
||||
normals_xy=np.ascontiguousarray(normals) if normals is not None else None,
|
||||
depth=np.ascontiguousarray(depth) if depth is not None else None)
|
||||
|
||||
def normalize_asset(asset: RawAsset,size: str|None,source_scale:float|None=None)->RawAsset:
|
||||
target=_target_size(size)
|
||||
scale=1.0 if source_scale is None else float(source_scale)
|
||||
return replace(asset,frames=tuple(normalize_frame(frame,target,source_scale=scale) for frame in asset.frames))
|
||||
|
||||
def build_palette(assets: Iterable[RawAsset])->list[tuple[int,int,int]]:
|
||||
chunks=[]
|
||||
for asset in assets:
|
||||
for frame in asset.frames:
|
||||
mask=frame.rgba[...,3]!=0
|
||||
if mask.any():chunks.append(frame.rgba[mask,:3])
|
||||
if not chunks: raise ValueError("cannot build palette from empty assets")
|
||||
colors=np.concatenate(chunks)
|
||||
if len(colors)>1_000_000:colors=colors[::math.ceil(len(colors)/1_000_000)]
|
||||
image=Image.fromarray(colors.reshape(1,-1,3),"RGB")
|
||||
quantized=image.quantize(colors=255,method=Image.Quantize.MEDIANCUT,dither=Image.Dither.NONE)
|
||||
used=quantized.getcolors() or []
|
||||
indices=sorted(index for _,index in used)
|
||||
raw=quantized.getpalette() or []
|
||||
palette=[(0,0,0)]+[tuple(raw[index*3:index*3+3]) for index in indices]
|
||||
palette=palette[:256]
|
||||
palette.extend([(0,0,0)]*(256-len(palette)))
|
||||
return palette
|
||||
|
||||
def _indexed(rgba: np.ndarray,palette: list[tuple[int,int,int]],dither: bool)->bytes:
|
||||
rgb=rgba[...,:3].astype(np.float32)
|
||||
if dither:
|
||||
tiled=np.tile(BAYER4,(math.ceil(rgba.shape[0]/4),math.ceil(rgba.shape[1]/4)))[:rgba.shape[0],:rgba.shape[1]]
|
||||
rgb=np.clip(rgb+tiled[...,None]*1.5,0,255)
|
||||
opaque=rgba[...,3]!=0; flat=rgb.reshape(-1,3); result=np.zeros(len(flat),dtype=np.uint8)
|
||||
candidates=np.asarray(palette[1:],dtype=np.float32)
|
||||
positions=np.flatnonzero(opaque.reshape(-1))
|
||||
for start in range(0,len(positions),8192):
|
||||
selected=positions[start:start+8192]
|
||||
distance=((flat[selected,None,:]-candidates[None,:,:])**2).sum(axis=2)
|
||||
result[selected]=distance.argmin(axis=1).astype(np.uint8)+1
|
||||
return result.tobytes()
|
||||
|
||||
def _normal_depth(rgba: np.ndarray)->tuple[bytes,bytes]:
|
||||
alpha=(rgba[...,3]!=0).astype(np.float32)
|
||||
gy=(np.gradient(alpha,axis=0) if alpha.shape[0]>1 else np.zeros_like(alpha))
|
||||
gx=(np.gradient(alpha,axis=1) if alpha.shape[1]>1 else np.zeros_like(alpha))
|
||||
nx=-gx*.65;ny=gy*.65
|
||||
length=np.sqrt(nx*nx+ny*ny+1.0);nx/=length;ny/=length
|
||||
normals=np.stack((np.clip(np.rint(nx*127),-127,127),np.clip(np.rint(ny*127),-127,127)),axis=2).astype(np.int8)
|
||||
normals[alpha==0]=0
|
||||
h=rgba.shape[0];depth=np.zeros(alpha.shape,dtype=np.uint8)
|
||||
if h>1:
|
||||
values=np.rint(np.linspace(255,0,h)).astype(np.uint8)
|
||||
depth=np.where(alpha!=0,values[:,None],0).astype(np.uint8)
|
||||
return normals.view(np.uint8).tobytes(),depth.tobytes()
|
||||
|
||||
def encode_frames(asset:RawAsset,palette:list[tuple[int,int,int]],dither:bool=False)->tuple[Frame,...]:
|
||||
output=[]
|
||||
for raw in asset.frames:
|
||||
h,w=raw.rgba.shape[:2]
|
||||
if raw.normals_xy is not None and raw.depth is not None:
|
||||
normals,depth=raw.normals_xy.tobytes(),raw.depth.tobytes()
|
||||
else:normals,depth=_normal_depth(raw.rgba)
|
||||
output.append(Frame(_indexed(raw.rgba,palette,dither),w,h,raw.pivot_x,raw.pivot_y,
|
||||
raw.duration_ms,normals,depth,raw.depth_min,raw.depth_max))
|
||||
return tuple(output)
|
||||
79
src/spriteforge/reader.py
Normal file
79
src/spriteforge/reader.py
Normal file
@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import struct
|
||||
from .format import (ANIM, DIRECTION, FLAG_DEPTH, FLAG_NORMALS, FRAME, HEADER, MAGIC,
|
||||
VERSION, Frame, fnv1a32)
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SfaFile:
|
||||
flags: int
|
||||
animations: tuple[tuple[str,int,int],...]
|
||||
directions: tuple[int, ...]
|
||||
frames: tuple[Frame, ...]
|
||||
layer_orders: tuple[tuple[int, ...], ...]
|
||||
|
||||
@property
|
||||
def animation(self)->str:
|
||||
return self.animations[0][0]
|
||||
|
||||
def _slice(data: bytes, off: int, size: int, label: str) -> memoryview:
|
||||
if off < 0 or size < 0 or off > len(data) or size > len(data)-off:
|
||||
raise ValueError(f"{label} outside file")
|
||||
return memoryview(data)[off:off+size]
|
||||
|
||||
def _stream(data: bytes, off: int, size: int, w: int, h: int, bpp: int) -> tuple[bytes, bytes]:
|
||||
src=_slice(data,off,size,"stream"); table=4*(h+1)
|
||||
if len(src)<table: raise ValueError("truncated row table")
|
||||
offsets=struct.unpack_from(f"<{h+1}I",src)
|
||||
if offsets[0]<table or offsets[-1]!=len(src) or any(a>b for a,b in zip(offsets,offsets[1:])):
|
||||
raise ValueError("invalid row offsets")
|
||||
out=bytearray(w*h*bpp); mask=bytearray(w*h)
|
||||
for y in range(h):
|
||||
pos,end=offsets[y],offsets[y+1]
|
||||
if end-pos<2: raise ValueError("truncated row")
|
||||
count=struct.unpack_from("<H",src,pos)[0]; pos+=2; previous=0
|
||||
for _ in range(count):
|
||||
if end-pos<4: raise ValueError("truncated span")
|
||||
x,n=struct.unpack_from("<HH",src,pos); pos+=4; payload=n*bpp
|
||||
if not n or x<previous or x+n>w or end-pos<payload: raise ValueError("invalid span")
|
||||
begin=(y*w+x)*bpp; out[begin:begin+payload]=src[pos:pos+payload]
|
||||
mask[y*w+x:y*w+x+n]=b"\1"*n; previous=x+n; pos+=payload
|
||||
if pos!=end: raise ValueError("trailing row bytes")
|
||||
return bytes(out),bytes(mask)
|
||||
|
||||
def decode_sfa(data: bytes) -> SfaFile:
|
||||
if len(data)<HEADER.size: raise ValueError("truncated header")
|
||||
f=HEADER.unpack_from(data)
|
||||
magic,major,minor,flags,file_size,ac,dc,fc,lc,res,strings,ao,do,fo,lo,_payload,r0,r1,r2=f
|
||||
if magic!=MAGIC or file_size!=len(data): raise ValueError("bad magic or size")
|
||||
if major!=VERSION[0] or minor>VERSION[1]: raise ValueError("unsupported version")
|
||||
if flags&~(FLAG_NORMALS|FLAG_DEPTH) or res or r0 or r1 or r2 or ac<1: raise ValueError("unsupported header")
|
||||
animations=[];expected_first=0
|
||||
for i in range(ac):
|
||||
ah,aname,first,per_dir,adirs=ANIM.unpack(_slice(data,ao+i*ANIM.size,ANIM.size,"animation"))
|
||||
start=strings+aname; end=data.find(b"\0",start,ao)
|
||||
if end<0: raise ValueError("bad animation name")
|
||||
name=data[start:end].decode()
|
||||
if fnv1a32(name)!=ah or first!=expected_first or adirs!=dc or per_dir<1: raise ValueError("bad animation")
|
||||
animations.append((name,first,per_dir));expected_first+=per_dir*dc
|
||||
if expected_first!=fc:raise ValueError("animation ranges do not cover frames")
|
||||
first_per_dir=animations[0][2]
|
||||
directions=[]
|
||||
for i in range(dc):
|
||||
angle,r,first=DIRECTION.unpack(_slice(data,do+i*DIRECTION.size,DIRECTION.size,"direction"))
|
||||
if r or first!=i*first_per_dir: raise ValueError("bad direction")
|
||||
directions.append(angle)
|
||||
frames=[]
|
||||
for i in range(fc):
|
||||
row=FRAME.unpack(_slice(data,fo+i*FRAME.size,FRAME.size,"frame"))
|
||||
w,h,px,py,ms,ff,dmin,dmax,co,cs,no,ns,zo,zs,rr=row
|
||||
if rr or ff!=flags: raise ValueError("bad frame")
|
||||
indices,mask=_stream(data,co,cs,w,h,1)
|
||||
if any(indices[j]==0 for j,v in enumerate(mask) if v): raise ValueError("zero in span")
|
||||
normals=_stream(data,no,ns,w,h,2)[0] if flags&FLAG_NORMALS else None
|
||||
depth=_stream(data,zo,zs,w,h,1)[0] if flags&FLAG_DEPTH else None
|
||||
frames.append(Frame(indices,w,h,px,py,ms,normals,depth,dmin,dmax))
|
||||
values=struct.unpack(f"<{fc*lc}H",_slice(data,lo,fc*lc*2,"layers"))
|
||||
orders=tuple(tuple(values[i*lc:(i+1)*lc]) for i in range(fc))
|
||||
if any(sorted(x)!=list(range(lc)) for x in orders): raise ValueError("bad layer order")
|
||||
return SfaFile(flags,tuple(animations),tuple(directions),tuple(frames),orders)
|
||||
1
src/spriteforge/relay/__init__.py
Normal file
1
src/spriteforge/relay/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Asynchronous pull relay for GPU workers behind NAT."""
|
||||
54
src/spriteforge/relay/client.py
Normal file
54
src/spriteforge/relay/client.py
Normal file
@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from urllib.request import Request,urlopen
|
||||
from urllib.error import HTTPError
|
||||
|
||||
|
||||
class RelayClient:
|
||||
def __init__(self,endpoint:str,token:str,timeout:float=60):
|
||||
self.endpoint=endpoint.rstrip("/");self.token=token;self.timeout=timeout
|
||||
|
||||
def health(self)->dict:return self.json("GET","/health",auth=False)
|
||||
def ping_client(self)->dict:return self.json("GET","/v1/client/ping")
|
||||
def ping_worker(self)->dict:return self.json("GET","/v1/worker/ping")
|
||||
def upload(self,data:bytes)->str:
|
||||
digest=hashlib.sha256(data).hexdigest();self.raw("PUT",f"/v1/blobs/{digest}",data,"application/octet-stream");return digest
|
||||
def download(self,digest:str)->bytes:return self.raw("GET",f"/v1/blobs/{digest}")
|
||||
def submit(self,payload:dict)->dict:return self.json("POST","/v1/jobs",payload)
|
||||
def status(self,job_id:str)->dict:return self.json("GET",f"/v1/jobs/{job_id}")
|
||||
def cancel(self,job_id:str)->dict:return self.json("POST",f"/v1/jobs/{job_id}/cancel",{})
|
||||
def lease(self,worker_id:str,lease_seconds:int)->dict|None:return self.json("POST","/v1/lease",{"worker_id":worker_id,"lease_seconds":lease_seconds})["job"]
|
||||
def heartbeat(self,job_id:str,worker_id:str,lease_seconds:int)->dict:return self.json("POST",f"/v1/jobs/{job_id}/heartbeat",{"worker_id":worker_id,"lease_seconds":lease_seconds})
|
||||
def complete(self,job_id:str,worker_id:str,outputs:list[str])->dict:return self.json("POST",f"/v1/jobs/{job_id}/complete",{"worker_id":worker_id,"outputs":outputs})
|
||||
def fail(self,job_id:str,worker_id:str,error:str)->dict:return self.json("POST",f"/v1/jobs/{job_id}/fail",{"worker_id":worker_id,"error":error[:4000]})
|
||||
def wait(self,job_id:str,timeout:float,poll:float=.8)->dict:
|
||||
deadline=time.monotonic()+timeout
|
||||
while time.monotonic()<deadline:
|
||||
job=self.status(job_id)
|
||||
if job["status"] in {"succeeded","failed","cancelled"}:return job
|
||||
time.sleep(poll)
|
||||
raise TimeoutError(f"relay job timed out after {timeout:g}s: {job_id}")
|
||||
|
||||
def json(self,method:str,path:str,value:dict|None=None,auth:bool=True)->dict:
|
||||
data=json.dumps(value).encode() if value is not None else None
|
||||
try:decoded=json.loads(self.raw(method,path,data,"application/json" if data is not None else None,auth))
|
||||
except json.JSONDecodeError as error:raise ValueError(f"relay returned invalid JSON: {path}") from error
|
||||
if not isinstance(decoded,dict):raise ValueError("relay returned non-object JSON")
|
||||
return decoded
|
||||
|
||||
def raw(self,method:str,path:str,data:bytes|None=None,content_type:str|None=None,auth:bool=True)->bytes:
|
||||
headers={}
|
||||
if auth:headers["Authorization"]=f"Bearer {self.token}"
|
||||
if content_type:headers["Content-Type"]=content_type
|
||||
request=Request(self.endpoint+path,data=data,method=method,headers=headers)
|
||||
try:
|
||||
with urlopen(request,timeout=self.timeout) as response:return response.read()
|
||||
except HTTPError as error:
|
||||
body=error.read().decode(errors="replace")
|
||||
try:message=json.loads(body).get("error",body)
|
||||
except json.JSONDecodeError:message=body
|
||||
raise ValueError(f"relay {method} {path} failed ({error.code}): {message}") from error
|
||||
except OSError as error:raise ValueError(f"relay request failed: {method} {path}: {error}") from error
|
||||
182
src/spriteforge/relay/server.py
Normal file
182
src/spriteforge/relay/server.py
Normal file
@ -0,0 +1,182 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid
|
||||
|
||||
_HASH=re.compile(r"^[0-9a-f]{64}$")
|
||||
_MAX_JSON=8*1024*1024
|
||||
_MAX_BLOB=64*1024*1024
|
||||
|
||||
|
||||
class RelayStore:
|
||||
def __init__(self,root:Path,ttl_hours:int=72):
|
||||
self.root=root.resolve();self.blobs=self.root/"blobs";self.database=self.root/"relay.sqlite3";self.ttl_seconds=ttl_hours*3600
|
||||
self.root.mkdir(parents=True,exist_ok=True);self.blobs.mkdir(parents=True,exist_ok=True);self._last_cleanup=0.0;self._init()
|
||||
|
||||
def connect(self):
|
||||
db=sqlite3.connect(self.database,timeout=30,isolation_level=None);db.row_factory=sqlite3.Row;db.execute("PRAGMA journal_mode=WAL");return db
|
||||
|
||||
def _init(self):
|
||||
with self.connect() as db:db.execute("""CREATE TABLE IF NOT EXISTS jobs(
|
||||
id TEXT PRIMARY KEY,status TEXT NOT NULL,payload TEXT NOT NULL,created REAL NOT NULL,updated REAL NOT NULL,
|
||||
lease_until REAL,worker_id TEXT,attempts INTEGER NOT NULL DEFAULT 0,outputs TEXT,error TEXT)""")
|
||||
|
||||
def blob_path(self,digest:str)->Path:
|
||||
if not _HASH.fullmatch(digest):raise ValueError("invalid blob hash")
|
||||
return self.blobs/digest[:2]/digest[2:4]/digest
|
||||
|
||||
def put_blob(self,digest:str,data:bytes)->None:
|
||||
if hashlib.sha256(data).hexdigest()!=digest:raise ValueError("blob SHA-256 does not match URL")
|
||||
path=self.blob_path(digest);path.parent.mkdir(parents=True,exist_ok=True)
|
||||
if not path.exists():path.write_bytes(data)
|
||||
|
||||
def create_job(self,payload:dict)->dict:
|
||||
job_id=f"job_{uuid.uuid4().hex}";now=time.time()
|
||||
with self.connect() as db:db.execute("INSERT INTO jobs(id,status,payload,created,updated) VALUES(?,?,?,?,?)",(job_id,"queued",json.dumps(payload,separators=(",",":")),now,now))
|
||||
self.maybe_cleanup();return self.get_job(job_id)
|
||||
|
||||
def get_job(self,job_id:str)->dict:
|
||||
with self.connect() as db:row=db.execute("SELECT * FROM jobs WHERE id=?",(job_id,)).fetchone()
|
||||
if row is None:raise KeyError(job_id)
|
||||
return self._record(row)
|
||||
|
||||
def lease(self,worker_id:str,seconds:int)->dict|None:
|
||||
self.maybe_cleanup()
|
||||
now=time.time()
|
||||
with self.connect() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
row=db.execute("SELECT * FROM jobs WHERE status='queued' OR (status='running' AND lease_until<?) ORDER BY created LIMIT 1",(now,)).fetchone()
|
||||
if row is None:db.execute("COMMIT");return None
|
||||
until=now+seconds;db.execute("UPDATE jobs SET status='running',updated=?,lease_until=?,worker_id=?,attempts=attempts+1 WHERE id=?",(now,until,worker_id,row["id"]));db.execute("COMMIT")
|
||||
return self.get_job(row["id"])
|
||||
|
||||
def cancel(self,job_id:str)->dict:
|
||||
now=time.time()
|
||||
with self.connect() as db:
|
||||
cursor=db.execute("UPDATE jobs SET status='cancelled',updated=?,lease_until=NULL,error='cancelled by client' WHERE id=? AND status IN ('queued','running')",(now,job_id))
|
||||
if not cursor.rowcount:raise ValueError("job cannot be cancelled")
|
||||
return self.get_job(job_id)
|
||||
|
||||
def heartbeat(self,job_id:str,worker_id:str,seconds:int)->dict:
|
||||
now=time.time()
|
||||
with self.connect() as db:
|
||||
cursor=db.execute("UPDATE jobs SET lease_until=?,updated=? WHERE id=? AND status='running' AND worker_id=?",(now+seconds,now,job_id,worker_id))
|
||||
if not cursor.rowcount:raise ValueError("job is not leased by this worker")
|
||||
return self.get_job(job_id)
|
||||
|
||||
def finish(self,job_id:str,worker_id:str,outputs:list[str]|None,error:str="")->dict:
|
||||
for digest in outputs or []:
|
||||
if not self.blob_path(digest).is_file():raise ValueError(f"output blob is missing: {digest}")
|
||||
status="failed" if error else "succeeded";now=time.time()
|
||||
with self.connect() as db:
|
||||
cursor=db.execute("UPDATE jobs SET status=?,updated=?,lease_until=NULL,outputs=?,error=? WHERE id=? AND status='running' AND worker_id=?",
|
||||
(status,now,json.dumps(outputs or []),error,job_id,worker_id))
|
||||
if not cursor.rowcount:raise ValueError("job is not leased by this worker")
|
||||
return self.get_job(job_id)
|
||||
|
||||
def cleanup(self)->dict:
|
||||
cutoff=time.time()-self.ttl_seconds
|
||||
with self.connect() as db:
|
||||
rows=db.execute("SELECT payload,outputs FROM jobs WHERE updated<? AND status IN ('succeeded','failed','cancelled')",(cutoff,)).fetchall()
|
||||
deleted=db.execute("DELETE FROM jobs WHERE updated<? AND status IN ('succeeded','failed','cancelled')",(cutoff,)).rowcount
|
||||
live=set()
|
||||
for row in db.execute("SELECT payload,outputs FROM jobs"):
|
||||
text=(row["payload"] or "")+(row["outputs"] or "");live.update(re.findall(r"[0-9a-f]{64}",text))
|
||||
blobs=0
|
||||
for path in self.blobs.glob("*/*/*"):
|
||||
if path.name not in live and path.stat().st_mtime<cutoff:path.unlink();blobs+=1
|
||||
return {"jobs":deleted,"blobs":blobs}
|
||||
|
||||
def maybe_cleanup(self)->None:
|
||||
now=time.time()
|
||||
if now-self._last_cleanup<3600:return
|
||||
self._last_cleanup=now;self.cleanup()
|
||||
|
||||
@staticmethod
|
||||
def _record(row)->dict:
|
||||
return {"id":row["id"],"status":row["status"],"payload":json.loads(row["payload"]),"created":row["created"],"updated":row["updated"],
|
||||
"lease_until":row["lease_until"],"worker_id":row["worker_id"],"attempts":row["attempts"],"outputs":json.loads(row["outputs"] or "[]"),"error":row["error"] or ""}
|
||||
|
||||
|
||||
class RelayServer(ThreadingHTTPServer):
|
||||
daemon_threads=True
|
||||
def __init__(self,address,store:RelayStore,client_token:str,worker_token:str):
|
||||
if not client_token or not worker_token:raise ValueError("relay tokens must be non-empty")
|
||||
self.store=store;self.client_token=client_token;self.worker_token=worker_token
|
||||
super().__init__(address,RelayHandler)
|
||||
|
||||
|
||||
class RelayHandler(BaseHTTPRequestHandler):
|
||||
server:RelayServer
|
||||
def log_message(self,format,*args):return
|
||||
def do_GET(self):
|
||||
try:
|
||||
if self.path=="/health":return self.reply({"ok":True})
|
||||
if self.path=="/v1/client/ping":self.auth("client");return self.reply({"ok":True,"role":"client"})
|
||||
if self.path=="/v1/worker/ping":self.auth("worker");return self.reply({"ok":True,"role":"worker"})
|
||||
if self.path.startswith("/v1/blobs/"):
|
||||
self.auth("either");digest=self.path.rsplit("/",1)[-1];path=self.server.store.blob_path(digest)
|
||||
if not path.is_file():return self.send_error(404)
|
||||
return self.bytes(path.read_bytes(),"application/octet-stream")
|
||||
if self.path.startswith("/v1/jobs/"):
|
||||
self.auth("client");return self.reply(self.server.store.get_job(self.path.rsplit("/",1)[-1]))
|
||||
self.send_error(404)
|
||||
except KeyError:self.send_error(404)
|
||||
except (ValueError,OSError) as error:self.problem(error)
|
||||
def do_PUT(self):
|
||||
try:
|
||||
if not self.path.startswith("/v1/blobs/"):return self.send_error(404)
|
||||
self.auth("either");data=self.body(_MAX_BLOB);digest=self.path.rsplit("/",1)[-1];self.server.store.put_blob(digest,data);self.reply({"hash":digest},HTTPStatus.CREATED)
|
||||
except (ValueError,OSError) as error:self.problem(error)
|
||||
def do_POST(self):
|
||||
try:
|
||||
if self.path=="/v1/jobs":self.auth("client");return self.reply(self.server.store.create_job(self.json_body()),HTTPStatus.CREATED)
|
||||
if self.path=="/v1/lease":
|
||||
self.auth("worker");p=self.json_body();job=self.server.store.lease(str(p["worker_id"]),max(30,min(600,int(p.get("lease_seconds",120)))));return self.reply({"job":job})
|
||||
match=re.fullmatch(r"/v1/jobs/([^/]+)/(heartbeat|complete|fail)",self.path)
|
||||
if match:
|
||||
self.auth("worker");p=self.json_body();job_id,action=match.groups();worker=str(p["worker_id"])
|
||||
if action=="heartbeat":job=self.server.store.heartbeat(job_id,worker,max(30,min(600,int(p.get("lease_seconds",120)))))
|
||||
elif action=="complete":job=self.server.store.finish(job_id,worker,list(p.get("outputs",[])))
|
||||
else:job=self.server.store.finish(job_id,worker,None,str(p.get("error","worker failed")))
|
||||
return self.reply(job)
|
||||
match=re.fullmatch(r"/v1/jobs/([^/]+)/cancel",self.path)
|
||||
if match:self.auth("client");return self.reply(self.server.store.cancel(match.group(1)))
|
||||
if self.path=="/v1/cleanup":self.auth("client");return self.reply(self.server.store.cleanup())
|
||||
self.send_error(404)
|
||||
except (KeyError,TypeError,ValueError,OSError) as error:self.problem(error)
|
||||
def auth(self,role:str):
|
||||
value=self.headers.get("Authorization","").removeprefix("Bearer ")
|
||||
allowed=[]
|
||||
if role in {"client","either"}:allowed.append(self.server.client_token)
|
||||
if role in {"worker","either"}:allowed.append(self.server.worker_token)
|
||||
if not any(hmac.compare_digest(value,item) for item in allowed):raise ValueError("unauthorized")
|
||||
def body(self,limit:int)->bytes:
|
||||
try:length=int(self.headers.get("Content-Length","0"))
|
||||
except ValueError:raise ValueError("invalid Content-Length")
|
||||
if length<0 or length>limit:raise ValueError("request is too large")
|
||||
return self.rfile.read(length)
|
||||
def json_body(self)->dict:
|
||||
try:value=json.loads(self.body(_MAX_JSON))
|
||||
except json.JSONDecodeError as error:raise ValueError("invalid JSON") from error
|
||||
if not isinstance(value,dict):raise ValueError("JSON body must be an object")
|
||||
return value
|
||||
def problem(self,error):self.reply({"error":str(error)},HTTPStatus.UNAUTHORIZED if str(error)=="unauthorized" else HTTPStatus.BAD_REQUEST)
|
||||
def reply(self,value,status=HTTPStatus.OK):self.bytes(json.dumps(value,separators=(",",":")).encode(),"application/json",status)
|
||||
def bytes(self,data,content_type,status=HTTPStatus.OK):
|
||||
self.send_response(status);self.send_header("Content-Type",content_type);self.send_header("Content-Length",str(len(data)));self.send_header("Cache-Control","no-store");self.end_headers();self.wfile.write(data)
|
||||
|
||||
|
||||
def serve(root:Path,host:str,port:int,client_token:str,worker_token:str,ttl_hours:int=72)->None:
|
||||
server=RelayServer((host,port),RelayStore(root,ttl_hours),client_token,worker_token)
|
||||
print(f"SpriteForge relay listening on http://{host}:{server.server_port}")
|
||||
try:server.serve_forever()
|
||||
finally:server.server_close()
|
||||
65
src/spriteforge/relay/worker.py
Normal file
65
src/spriteforge/relay/worker.py
Normal file
@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from threading import Event,Thread
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from spriteforge.studio.models import GenerationRequest
|
||||
from spriteforge.studio.providers import ComfyUIProvider
|
||||
from .client import RelayClient
|
||||
|
||||
|
||||
class GpuWorker:
|
||||
def __init__(self,relay_url:str,token:str,comfy_url:str="http://127.0.0.1:8188",worker_id:str="",lease_seconds:int=180):
|
||||
self.client=RelayClient(relay_url,token);self.comfy_url=comfy_url.rstrip("/");self.worker_id=worker_id or f"worker-{uuid.uuid4().hex[:12]}";self.lease_seconds=max(60,lease_seconds)
|
||||
|
||||
def run(self,poll_interval:float=2)->None:
|
||||
self.check()
|
||||
print(f"SpriteForge GPU worker {self.worker_id}; relay={self.client.endpoint}; comfy={self.comfy_url}")
|
||||
while True:
|
||||
job=self.client.lease(self.worker_id,self.lease_seconds)
|
||||
if job is None:time.sleep(poll_interval);continue
|
||||
self.process(job)
|
||||
|
||||
def once(self)->bool:
|
||||
job=self.client.lease(self.worker_id,self.lease_seconds)
|
||||
if job is None:return False
|
||||
self.process(job);return True
|
||||
|
||||
def check(self)->dict:
|
||||
relay=self.client.ping_worker();comfy=ComfyUIProvider(self.comfy_url,Path("unused-workflow.json")).check()
|
||||
print(f"preflight OK: relay role={relay['role']}; ComfyUI reachable")
|
||||
return {"relay":relay,"comfy":comfy}
|
||||
|
||||
def process(self,job:dict)->None:
|
||||
stop=Event();cancelled=Event()
|
||||
def heartbeat():
|
||||
while not stop.wait(self.lease_seconds/3):
|
||||
try:self.client.heartbeat(job["id"],self.worker_id,self.lease_seconds)
|
||||
except ValueError:cancelled.set();self._interrupt();return
|
||||
thread=Thread(target=heartbeat,daemon=True);thread.start()
|
||||
try:
|
||||
payload=job["payload"]
|
||||
if payload.get("protocol")!=1:raise ValueError("unsupported relay job protocol")
|
||||
request=GenerationRequest.model_validate(payload["request"])
|
||||
workflow=self.client.download(payload["workflow_hash"])
|
||||
with tempfile.TemporaryDirectory(prefix="spriteforge-worker-") as directory:
|
||||
path=Path(directory)/"workflow.json";path.write_bytes(workflow)
|
||||
provider=ComfyUIProvider(self.comfy_url,path,timeout=max(60,self.lease_seconds*10))
|
||||
outputs=provider.generate(request,self.client.download)
|
||||
hashes=[self.client.upload(image) for image in outputs]
|
||||
if cancelled.is_set():return
|
||||
self.client.complete(job["id"],self.worker_id,hashes)
|
||||
print(f"completed {job['id']}: {len(hashes)} image(s)")
|
||||
except Exception as error:
|
||||
try:self.client.fail(job["id"],self.worker_id,str(error))
|
||||
except ValueError:pass
|
||||
print(f"failed {job['id']}: {error}")
|
||||
finally:stop.set();thread.join(timeout=1)
|
||||
|
||||
def _interrupt(self)->None:
|
||||
from urllib.request import Request,urlopen
|
||||
try:urlopen(Request(self.comfy_url+"/interrupt",data=b"{}",method="POST",headers={"Content-Type":"application/json"}),timeout=5).close()
|
||||
except OSError:pass
|
||||
6
src/spriteforge/studio/__init__.py
Normal file
6
src/spriteforge/studio/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""Human-facing SpriteForge asset authoring studio."""
|
||||
|
||||
from .models import GenerationRequest, Project
|
||||
from .store import ProjectStore
|
||||
|
||||
__all__ = ["GenerationRequest", "Project", "ProjectStore"]
|
||||
61
src/spriteforge/studio/config.py
Normal file
61
src/spriteforge/studio/config.py
Normal file
@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Literal
|
||||
|
||||
from .providers import ComfyUIProvider,RelayProvider
|
||||
|
||||
|
||||
class GpuProfile(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
name: str = Field(pattern=r"^[a-z][a-z0-9_-]*$")
|
||||
kind: Literal["comfyui","relay"] = "comfyui"
|
||||
endpoint: str
|
||||
workflow: str
|
||||
token_env: str = ""
|
||||
timeout_seconds: float = Field(default=900, gt=0, le=7200)
|
||||
|
||||
|
||||
class UserConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
version: int = 1
|
||||
profiles: list[GpuProfile] = Field(default_factory=list)
|
||||
|
||||
|
||||
def default_config_path() -> Path:
|
||||
if os.name == "nt":
|
||||
root = Path(os.environ.get("LOCALAPPDATA", Path.home()/"AppData"/"Local"))
|
||||
return root/"SpriteForge"/"providers.json"
|
||||
root = Path(os.environ.get("XDG_CONFIG_HOME", Path.home()/".config"))
|
||||
return root/"spriteforge"/"providers.json"
|
||||
|
||||
|
||||
def load_config(path: Path | None = None) -> UserConfig:
|
||||
source = (path or default_config_path()).resolve()
|
||||
if not source.is_file(): return UserConfig()
|
||||
return UserConfig.model_validate_json(source.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def save_profile(profile: GpuProfile, path: Path | None = None) -> Path:
|
||||
destination = (path or default_config_path()).resolve();config=load_config(destination)
|
||||
config.profiles=[item for item in config.profiles if item.name!=profile.name]+[profile]
|
||||
destination.parent.mkdir(parents=True,exist_ok=True)
|
||||
temporary=destination.with_suffix(".tmp");temporary.write_text(config.model_dump_json(indent=2)+"\n",encoding="utf-8");temporary.replace(destination)
|
||||
return destination
|
||||
|
||||
|
||||
def get_profile(name: str, path: Path | None = None) -> GpuProfile:
|
||||
profile=next((item for item in load_config(path).profiles if item.name==name),None)
|
||||
if profile is None:raise ValueError(f"GPU profile not found: {name}")
|
||||
return profile
|
||||
|
||||
|
||||
def provider_from_profile(profile: GpuProfile):
|
||||
token=os.environ.get(profile.token_env,"") if profile.token_env else ""
|
||||
if profile.token_env and not token:raise ValueError(f"environment variable is empty: {profile.token_env}")
|
||||
if profile.kind=="relay":return RelayProvider(profile.endpoint,Path(profile.workflow),token,timeout=profile.timeout_seconds)
|
||||
return ComfyUIProvider(profile.endpoint,Path(profile.workflow),token,timeout=profile.timeout_seconds)
|
||||
100
src/spriteforge/studio/export.py
Normal file
100
src/spriteforge/studio/export.py
Normal file
@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from spriteforge.backends.base import RawFrame
|
||||
from spriteforge.backends.sequence import build_sequence_asset, direction_centidegrees
|
||||
from spriteforge.format import Animation, encode_sfa
|
||||
from spriteforge.palette import encode_sfp
|
||||
from spriteforge.postprocess import build_palette, encode_frames, normalize_asset
|
||||
from spriteforge.codegen import generate_header
|
||||
from spriteforge.watch import write_reload
|
||||
|
||||
from .models import Asset, Candidate
|
||||
from .store import ProjectStore
|
||||
|
||||
|
||||
def _approved(asset: Asset, shot_id: str, candidate_id: str | None) -> Candidate:
|
||||
shot = next(item for item in asset.shots if item.id == shot_id)
|
||||
if not candidate_id:
|
||||
raise ValueError(f"{asset.id}/{shot.animation}/dir{shot.direction}/frame{shot.frame}: no approved candidate")
|
||||
return next(item for item in shot.candidates if item.id == candidate_id)
|
||||
|
||||
|
||||
def _raw(store: ProjectStore, asset: Asset, candidate: Candidate, duration: int) -> RawFrame:
|
||||
with Image.open(store.media_path(candidate.image.path)) as source:
|
||||
rgba = np.asarray(source.convert("RGBA"), dtype=np.uint8)
|
||||
rgba=rgba.copy()
|
||||
process=candidate.process
|
||||
if process.chroma_key:
|
||||
key=np.asarray([int(process.chroma_key[i:i+2],16) for i in (1,3,5)],dtype=np.int32)
|
||||
distance=np.sqrt(((rgba[...,:3].astype(np.int32)-key)**2).sum(axis=2))
|
||||
rgba[distance<=process.chroma_tolerance,3]=0
|
||||
rgba[...,3]=np.where(rgba[...,3]>=process.alpha_threshold,255,0).astype(np.uint8)
|
||||
return RawFrame(rgba, round((rgba.shape[1]-1)*asset.pivot_x), round((rgba.shape[0]-1)*asset.pivot_y), duration)
|
||||
|
||||
|
||||
def compile_asset(store: ProjectStore, asset: Asset):
|
||||
if not asset.shots:
|
||||
raise ValueError(f"{asset.id}: no shots")
|
||||
animations = list(dict.fromkeys(shot.animation for shot in asset.shots))
|
||||
direction_count = max(shot.direction for shot in asset.shots) + 1
|
||||
if {shot.direction for shot in asset.shots} != set(range(direction_count)):
|
||||
raise ValueError(f"{asset.id}: directions must be contiguous from zero")
|
||||
duration = max(1, round(1000/asset.fps)); sequences = {}
|
||||
for animation in animations:
|
||||
related = [shot for shot in asset.shots if shot.animation == animation]
|
||||
max_frame = max(shot.frame for shot in related)
|
||||
rows = []
|
||||
for direction in range(direction_count):
|
||||
row = []
|
||||
for frame in range(max_frame+1):
|
||||
matches = [shot for shot in related if shot.direction == direction and shot.frame == frame]
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"{asset.id}/{animation}: need exactly one shot for dir {direction}, frame {frame}")
|
||||
shot = matches[0]
|
||||
row.append(_raw(store, asset, _approved(asset, shot.id, shot.approved_candidate_id), duration))
|
||||
rows.append(row)
|
||||
sequences[animation] = rows
|
||||
raw = build_sequence_asset(sequences, direction_centidegrees(direction_count))
|
||||
return normalize_asset(raw, f"{asset.target_width}x{asset.target_height}", source_scale=1.0)
|
||||
|
||||
|
||||
def export_project(store: ProjectStore, output: Path) -> tuple[Path, ...]:
|
||||
project = store.load()
|
||||
groups={name:[asset for asset in project.assets if asset.layer_group==name] for name in {a.layer_group for a in project.assets if a.layer_group}}
|
||||
for name,members in groups.items():
|
||||
base=members[0];base_coords={(s.animation,s.direction,s.frame) for s in base.shots}
|
||||
for member in members[1:]:
|
||||
coords={(s.animation,s.direction,s.frame) for s in member.shots}
|
||||
if coords!=base_coords:raise ValueError(f"layer group '{name}' has unsynchronized shot grids: {base.id} vs {member.id}")
|
||||
if (member.target_width,member.target_height)!=(base.target_width,base.target_height):raise ValueError(f"layer group '{name}' must share target dimensions")
|
||||
if (member.pivot_x,member.pivot_y)!=(base.pivot_x,base.pivot_y):raise ValueError(f"layer group '{name}' must share pivots")
|
||||
compiled = [(asset, compile_asset(store, asset)) for asset in project.assets if asset.shots]
|
||||
if not compiled:
|
||||
raise ValueError("project has no assets with approved shots")
|
||||
palette = build_palette(raw for _, raw in compiled)
|
||||
output = output.resolve(); output.mkdir(parents=True, exist_ok=True)
|
||||
files: list[Path] = []
|
||||
palette_path = output / "palettes.sfp"; palette_path.write_bytes(encode_sfp({"default": palette})); files.append(palette_path)
|
||||
catalog = {"version": 1, "project": project.id, "palette": {"file":"palettes.sfp"}, "assets": []}
|
||||
for asset, raw in compiled:
|
||||
frames = encode_frames(raw, palette)
|
||||
blob = encode_sfa(frames, animation=raw.animation, directions=raw.directions_centidegrees,
|
||||
animations=tuple(Animation(name, count) for name, count in raw.animations))
|
||||
path = output / f"{asset.id}.sfa"; path.write_bytes(blob); files.append(path)
|
||||
catalog["assets"].append({"id": asset.id, "file": path.name, "frames": len(frames),
|
||||
"directions": len(raw.directions_centidegrees),
|
||||
"hash":hashlib.sha256(blob).hexdigest(),
|
||||
"layer_group":asset.layer_group,"layer_order":asset.layer_order,
|
||||
"streams":{"normals":True,"depth":True},
|
||||
"animations": [{"name": n, "frames_per_direction": c} for n, c in raw.animations]})
|
||||
index = output / "index.json"; index.write_text(json.dumps(catalog, indent=2)+"\n", encoding="utf-8"); files.append(index)
|
||||
header=generate_header(output,output/"spriteforge_assets.h");files.append(header)
|
||||
write_reload(output,(asset.id for asset,_ in compiled));files.append(output/"reload.json")
|
||||
return tuple(files)
|
||||
67
src/spriteforge/studio/jobs.py
Normal file
67
src/spriteforge/studio/jobs.py
Normal file
@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from queue import Queue
|
||||
from threading import Thread
|
||||
|
||||
from .models import GenerationRequest, utc_now
|
||||
from .providers import create_provider
|
||||
from .store import ProjectStore
|
||||
|
||||
|
||||
class GenerationQueue:
|
||||
"""Single-worker queue for deterministic project writes and non-blocking HTTP."""
|
||||
|
||||
def __init__(self, store: ProjectStore, providers: dict[str, object] | None = None):
|
||||
self.store = store
|
||||
self.providers = providers or {}
|
||||
self.pending: Queue[tuple[str, GenerationRequest] | None] = Queue()
|
||||
self.worker = Thread(target=self._run, name="spriteforge-generation", daemon=True)
|
||||
self.worker.start()
|
||||
for job in self.store.load().jobs:
|
||||
if job.status == "queued":
|
||||
self.pending.put((job.id,job.request))
|
||||
elif job.status == "running":
|
||||
self.store.update_job(job.id,status="failed",finished_at=utc_now(),error="Studio stopped while this job was running; retry it")
|
||||
|
||||
def submit(self, request: GenerationRequest):
|
||||
job = self.store.create_job(request)
|
||||
self.pending.put((job.id, request))
|
||||
return job
|
||||
|
||||
def close(self) -> None:
|
||||
self.pending.put(None)
|
||||
|
||||
def cancel(self, job_id: str):
|
||||
job=self.store.get_job(job_id)
|
||||
if job.status not in {"queued","running"}:raise ValueError(f"cannot cancel job in state {job.status}")
|
||||
provider=self.providers.get(job.request.recipe.provider)
|
||||
if provider is not None and hasattr(provider,"cancel"):provider.cancel(job.request)
|
||||
return self.store.update_job(job_id,status="cancelled",finished_at=utc_now())
|
||||
|
||||
def retry(self, job_id: str):
|
||||
job=self.store.get_job(job_id)
|
||||
if job.status not in {"failed","cancelled"}:raise ValueError(f"cannot retry job in state {job.status}")
|
||||
return self.submit(job.request)
|
||||
|
||||
def _run(self) -> None:
|
||||
while True:
|
||||
item = self.pending.get()
|
||||
if item is None:
|
||||
return
|
||||
job_id, request = item
|
||||
if self.store.get_job(job_id).status=="cancelled":
|
||||
continue
|
||||
self.store.update_job(job_id, status="running", started_at=utc_now())
|
||||
candidate_ids = []
|
||||
try:
|
||||
provider = self.providers.get(request.recipe.provider) or create_provider(request.recipe.provider)
|
||||
for image in provider.generate(request, self.store.media_bytes_by_hash):
|
||||
if self.store.get_job(job_id).status=="cancelled":
|
||||
break
|
||||
media = self.store.import_png(image, "candidate")
|
||||
_, candidate = self.store.add_candidate(request.asset_id, request.shot_id, media, request.recipe)
|
||||
candidate_ids.append(candidate.id)
|
||||
if self.store.get_job(job_id).status!="cancelled":
|
||||
self.store.update_job(job_id, status="succeeded", finished_at=utc_now(), candidate_ids=candidate_ids)
|
||||
except Exception as error:
|
||||
self.store.update_job(job_id, status="failed", finished_at=utc_now(), error=str(error))
|
||||
170
src/spriteforge/studio/models.py
Normal file
170
src/spriteforge/studio/models.py
Normal file
@ -0,0 +1,170 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
PROJECT_VERSION = 1
|
||||
ID_PATTERN = r"^[a-z][a-z0-9_]*$"
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
class MediaRef(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
hash: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
path: str
|
||||
width: int = Field(gt=0, le=16384)
|
||||
height: int = Field(gt=0, le=16384)
|
||||
role: Literal["reference", "candidate", "mask", "control", "approved"]
|
||||
|
||||
|
||||
class StyleBible(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
description: str = ""
|
||||
positive_prompt: str = ""
|
||||
negative_prompt: str = ""
|
||||
camera: str = "isometric orthographic, fixed three-quarter view"
|
||||
lighting: str = ""
|
||||
palette_notes: str = ""
|
||||
|
||||
|
||||
class GenerationRecipe(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
provider: str
|
||||
model: str
|
||||
seed: int = Field(ge=0, le=2**63 - 1)
|
||||
prompt: str
|
||||
negative_prompt: str = ""
|
||||
width: int = Field(gt=0, le=4096)
|
||||
height: int = Field(gt=0, le=4096)
|
||||
reference_hashes: list[str] = Field(default_factory=list)
|
||||
controls: dict[str, Any] = Field(default_factory=dict)
|
||||
steps: int = Field(default=28,ge=1,le=200)
|
||||
cfg: float = Field(default=6.5,ge=0,le=30)
|
||||
denoise: float = Field(default=1.0,ge=0,le=1)
|
||||
sampler: str = "dpmpp_2m"
|
||||
scheduler: str = "karras"
|
||||
|
||||
|
||||
class CandidateProcess(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
alpha_threshold: int = Field(default=128, ge=0, le=255)
|
||||
chroma_key: str = Field(default="", pattern=r"^(|#[0-9A-Fa-f]{6})$")
|
||||
chroma_tolerance: int = Field(default=0, ge=0, le=441)
|
||||
|
||||
|
||||
class CandidateMetrics(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
opaque_fraction: float = Field(ge=0,le=1)
|
||||
bbox_fraction: float = Field(ge=0,le=1)
|
||||
centroid_x: float = Field(ge=0,le=1)
|
||||
centroid_y: float = Field(ge=0,le=1)
|
||||
neighbor_silhouette_iou: float | None = Field(default=None,ge=0,le=1)
|
||||
neighbor_color_distance: float | None = Field(default=None,ge=0)
|
||||
|
||||
|
||||
class Candidate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
id: str
|
||||
image: MediaRef
|
||||
recipe: GenerationRecipe
|
||||
created_at: str = Field(default_factory=utc_now)
|
||||
decision: Literal["pending", "approved", "rejected"] = "pending"
|
||||
notes: str = ""
|
||||
process: CandidateProcess = Field(default_factory=CandidateProcess)
|
||||
metrics: CandidateMetrics | None = None
|
||||
|
||||
|
||||
class Shot(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
id: str
|
||||
animation: str = "still"
|
||||
direction: int = Field(default=0, ge=0, le=31)
|
||||
frame: int = Field(default=0, ge=0, le=65535)
|
||||
candidates: list[Candidate] = Field(default_factory=list)
|
||||
approved_candidate_id: str | None = None
|
||||
controls: list[MediaRef] = Field(default_factory=list)
|
||||
prompt_override: str = ""
|
||||
negative_prompt_override: str = ""
|
||||
|
||||
|
||||
class Asset(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
id: str = Field(pattern=ID_PATTERN)
|
||||
name: str
|
||||
kind: Literal["character", "monster", "environment", "item", "effect", "other"]
|
||||
target_width: int = Field(gt=0, le=4096)
|
||||
target_height: int = Field(gt=0, le=4096)
|
||||
generation_width: int = Field(default=512, ge=64, le=4096)
|
||||
generation_height: int = Field(default=512, ge=64, le=4096)
|
||||
directions: int = Field(default=1, ge=1, le=32)
|
||||
fps: float = Field(default=12.0, gt=0, le=120)
|
||||
pivot_x: float = Field(default=0.5, ge=0.0, le=1.0)
|
||||
pivot_y: float = Field(default=1.0, ge=0.0, le=1.0)
|
||||
layer_group: str = Field(default="", pattern=r"^(|[a-z][a-z0-9_]*)$")
|
||||
layer_order: int = Field(default=0, ge=-128, le=127)
|
||||
prompt: str = ""
|
||||
negative_prompt: str = ""
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
references: list[MediaRef] = Field(default_factory=list)
|
||||
shots: list[Shot] = Field(default_factory=list)
|
||||
|
||||
@field_validator("tags")
|
||||
@classmethod
|
||||
def unique_tags(cls, value: list[str]) -> list[str]:
|
||||
return list(dict.fromkeys(value))
|
||||
|
||||
|
||||
class ProjectEvent(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
at: str = Field(default_factory=utc_now)
|
||||
action: str
|
||||
asset_id: str | None = None
|
||||
shot_id: str | None = None
|
||||
candidate_id: str | None = None
|
||||
|
||||
|
||||
class Project(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
version: Literal[1] = PROJECT_VERSION
|
||||
id: str = Field(pattern=ID_PATTERN)
|
||||
name: str
|
||||
created_at: str = Field(default_factory=utc_now)
|
||||
updated_at: str = Field(default_factory=utc_now)
|
||||
style: StyleBible = Field(default_factory=StyleBible)
|
||||
assets: list[Asset] = Field(default_factory=list)
|
||||
events: list[ProjectEvent] = Field(default_factory=list)
|
||||
jobs: list["GenerationJob"] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GenerationRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
project_id: str
|
||||
asset_id: str
|
||||
shot_id: str
|
||||
count: int = Field(default=4, ge=1, le=64)
|
||||
recipe: GenerationRecipe
|
||||
|
||||
|
||||
class GenerationJob(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
id: str
|
||||
request: GenerationRequest
|
||||
status: Literal["queued", "running", "succeeded", "failed", "cancelled"] = "queued"
|
||||
created_at: str = Field(default_factory=utc_now)
|
||||
started_at: str | None = None
|
||||
finished_at: str | None = None
|
||||
candidate_ids: list[str] = Field(default_factory=list)
|
||||
error: str = ""
|
||||
|
||||
|
||||
class ShotGridRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
asset_id: str
|
||||
animation: str = Field(min_length=1, max_length=64, pattern=r"^[a-z][a-z0-9_]*$")
|
||||
directions: int = Field(ge=1, le=32)
|
||||
frames: int = Field(ge=1, le=256)
|
||||
242
src/spriteforge/studio/providers.py
Normal file
242
src/spriteforge/studio/providers.py
Normal file
@ -0,0 +1,242 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import time
|
||||
import re
|
||||
from typing import ClassVar
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from .models import GenerationRequest
|
||||
|
||||
ImageResolver = Callable[[str], bytes]
|
||||
|
||||
|
||||
class GenerationProvider(ABC):
|
||||
"""Replaceable GPU boundary. Providers return PNG bytes and never touch a project."""
|
||||
|
||||
name: ClassVar[str]
|
||||
|
||||
@abstractmethod
|
||||
def generate(self, request: GenerationRequest, resolve_image: ImageResolver) -> list[bytes]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
_providers: dict[str, Callable[[], GenerationProvider]] = {}
|
||||
|
||||
|
||||
def register_provider(name: str, factory: Callable[[], GenerationProvider], *, replace: bool = False) -> None:
|
||||
if not name or (name in _providers and not replace):
|
||||
raise ValueError(f"generation provider already registered or invalid: {name}")
|
||||
_providers[name] = factory
|
||||
|
||||
|
||||
def create_provider(name: str) -> GenerationProvider:
|
||||
try:
|
||||
return _providers[name]()
|
||||
except KeyError as error:
|
||||
raise ValueError(f"generation provider is not configured: {name}") from error
|
||||
|
||||
|
||||
def provider_names() -> tuple[str, ...]:
|
||||
return tuple(sorted(_providers))
|
||||
|
||||
|
||||
class DiagnosticProvider(GenerationProvider):
|
||||
"""GPU-free end-to-end test card; deliberately not an art generator."""
|
||||
|
||||
name = "diagnostic"
|
||||
|
||||
def generate(self, request: GenerationRequest, resolve_image: ImageResolver) -> list[bytes]:
|
||||
output = []
|
||||
for number in range(request.count):
|
||||
image = Image.new("RGBA", (request.recipe.width, request.recipe.height), (10, 15, 16, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
margin = max(4, min(image.size) // 12)
|
||||
draw.rectangle((margin, margin, image.width-margin-1, image.height-margin-1),
|
||||
fill=(22, 31, 32, 255), outline=(168, 202, 66, 255), width=max(1, margin//4))
|
||||
draw.text((margin*2, margin*2), f"PIPELINE TEST\n{request.asset_id}\n{request.shot_id}\nseed {request.recipe.seed+number}",
|
||||
fill=(220, 228, 222, 255))
|
||||
blob = io.BytesIO(); image.save(blob, "PNG"); output.append(blob.getvalue())
|
||||
return output
|
||||
|
||||
|
||||
register_provider(DiagnosticProvider.name, DiagnosticProvider)
|
||||
|
||||
|
||||
def _replace(value, replacements: dict[str, object]):
|
||||
if isinstance(value, dict):
|
||||
return {key: _replace(item, replacements) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_replace(item, replacements) for item in value]
|
||||
if isinstance(value, str):
|
||||
if value in replacements:
|
||||
return replacements[value]
|
||||
for marker, replacement in replacements.items():
|
||||
value = value.replace(marker, str(replacement))
|
||||
return value
|
||||
|
||||
|
||||
_CORE_MARKERS={"{{PROMPT}}","{{NEGATIVE_PROMPT}}","{{SEED}}","{{WIDTH}}","{{HEIGHT}}"}
|
||||
_KNOWN_MARKERS=_CORE_MARKERS|{"{{BATCH_SIZE}}","{{MODEL}}","{{STEPS}}","{{CFG}}","{{DENOISE}}","{{SAMPLER}}","{{SCHEDULER}}"}
|
||||
|
||||
|
||||
def workflow_issues(path:Path)->list[str]:
|
||||
try:value=json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError,json.JSONDecodeError) as error:return [f"cannot read workflow JSON: {error}"]
|
||||
if not isinstance(value,dict):return ["workflow root must be a JSON object"]
|
||||
if isinstance(value.get("nodes"),list) or "links" in value:return ["workflow is UI format; export it with Save (API Format)"]
|
||||
if not value or any(not isinstance(node,dict) or "class_type" not in node or "inputs" not in node for node in value.values()):return ["workflow must be an API-format mapping of node ids to class_type/inputs"]
|
||||
text=json.dumps(value);present=set(re.findall(r"\{\{[A-Z0-9_]+\}\}",text));issues=[]
|
||||
missing=sorted(_CORE_MARKERS-present)
|
||||
if missing:issues.append("missing required placeholders: "+", ".join(missing))
|
||||
unknown=sorted(marker for marker in present if marker not in _KNOWN_MARKERS and not re.fullmatch(r"\{\{REFERENCE_[0-9]+\}\}",marker) and not re.fullmatch(r"\{\{CONTROL_[A-Z0-9_]+\}\}",marker))
|
||||
if unknown:issues.append("unknown placeholders: "+", ".join(unknown))
|
||||
return issues
|
||||
|
||||
|
||||
def require_valid_workflow(path:Path)->None:
|
||||
issues=workflow_issues(path)
|
||||
if issues:raise ValueError("invalid ComfyUI workflow: "+"; ".join(issues))
|
||||
|
||||
|
||||
class ComfyUIProvider(GenerationProvider):
|
||||
"""ComfyUI HTTP adapter driven by a user-exported API workflow JSON."""
|
||||
|
||||
name = "comfyui"
|
||||
|
||||
def __init__(self, endpoint: str, workflow: Path, token: str = "", *, timeout: float = 900,
|
||||
poll_interval: float = 0.5):
|
||||
self.endpoint = endpoint.rstrip("/")
|
||||
self.workflow_path = workflow.resolve()
|
||||
self.token = token
|
||||
self.timeout = timeout
|
||||
self.poll_interval = poll_interval
|
||||
|
||||
def check(self) -> dict:
|
||||
return self._json("GET", "/system_stats")
|
||||
|
||||
def generate(self, request: GenerationRequest, resolve_image: ImageResolver) -> list[bytes]:
|
||||
require_valid_workflow(self.workflow_path)
|
||||
workflow = json.loads(self.workflow_path.read_text(encoding="utf-8"))
|
||||
names = []
|
||||
for index, digest in enumerate(request.recipe.reference_hashes):
|
||||
names.append(self._upload(resolve_image(digest), f"sf_{digest}.png"))
|
||||
replacements: dict[str, object] = {
|
||||
"{{PROMPT}}": request.recipe.prompt,
|
||||
"{{NEGATIVE_PROMPT}}": request.recipe.negative_prompt,
|
||||
"{{SEED}}": request.recipe.seed,
|
||||
"{{WIDTH}}": request.recipe.width,
|
||||
"{{HEIGHT}}": request.recipe.height,
|
||||
"{{BATCH_SIZE}}": request.count,
|
||||
"{{MODEL}}": request.recipe.model,
|
||||
"{{STEPS}}": request.recipe.steps,
|
||||
"{{CFG}}": request.recipe.cfg,
|
||||
"{{DENOISE}}": request.recipe.denoise,
|
||||
"{{SAMPLER}}": request.recipe.sampler,
|
||||
"{{SCHEDULER}}": request.recipe.scheduler,
|
||||
}
|
||||
replacements.update({f"{{{{REFERENCE_{index}}}}}": name for index, name in enumerate(names)})
|
||||
for control_name, digest in request.recipe.controls.items():
|
||||
if not isinstance(digest, str):
|
||||
continue
|
||||
uploaded=self._upload(resolve_image(digest),f"sf_{control_name}_{digest}.png")
|
||||
replacements[f"{{{{CONTROL_{str(control_name).upper()}}}}}"]=uploaded
|
||||
prompt = _replace(workflow, replacements)
|
||||
response = self._json("POST", "/prompt", {"prompt": prompt, "client_id": f"spriteforge-{request.project_id}"})
|
||||
prompt_id = response.get("prompt_id")
|
||||
if not prompt_id:
|
||||
raise ValueError(f"ComfyUI did not return prompt_id: {response}")
|
||||
deadline = time.monotonic() + self.timeout
|
||||
while time.monotonic() < deadline:
|
||||
history = self._json("GET", f"/history/{prompt_id}")
|
||||
record = history.get(prompt_id)
|
||||
if record:
|
||||
images = []
|
||||
for output in record.get("outputs", {}).values():
|
||||
for image in output.get("images", []):
|
||||
query = urlencode({key: image.get(key, "") for key in ("filename", "subfolder", "type")})
|
||||
images.append(self._bytes("GET", f"/view?{query}"))
|
||||
if images:
|
||||
return images
|
||||
status = record.get("status", {})
|
||||
if status.get("status_str") == "error" or status.get("completed") is False:
|
||||
raise ValueError(f"ComfyUI generation failed: {status}")
|
||||
time.sleep(self.poll_interval)
|
||||
raise TimeoutError(f"ComfyUI generation timed out after {self.timeout:g}s")
|
||||
|
||||
def _upload(self, png: bytes, filename: str) -> str:
|
||||
boundary = "----spriteforge-boundary"
|
||||
body = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"image\"; filename=\"{filename}\"\r\n"
|
||||
"Content-Type: image/png\r\n\r\n").encode() + png + f"\r\n--{boundary}--\r\n".encode()
|
||||
response = self._json("POST", "/upload/image", raw=body,
|
||||
content_type=f"multipart/form-data; boundary={boundary}")
|
||||
return str(response.get("name") or filename)
|
||||
|
||||
def _headers(self, content_type: str | None = None) -> dict[str, str]:
|
||||
headers = {"Accept": "application/json"}
|
||||
if content_type:
|
||||
headers["Content-Type"] = content_type
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
return headers
|
||||
|
||||
def _bytes(self, method: str, path: str, data: bytes | None = None,
|
||||
content_type: str | None = None) -> bytes:
|
||||
request = Request(self.endpoint + path, data=data, method=method,
|
||||
headers=self._headers(content_type))
|
||||
try:
|
||||
with urlopen(request, timeout=min(self.timeout, 60)) as response:
|
||||
return response.read()
|
||||
except OSError as error:
|
||||
raise ValueError(f"ComfyUI request failed: {method} {path}: {error}") from error
|
||||
|
||||
def _json(self, method: str, path: str, value: object | None = None, *, raw: bytes | None = None,
|
||||
content_type: str = "application/json") -> dict:
|
||||
data = raw if raw is not None else (json.dumps(value).encode() if value is not None else None)
|
||||
try:
|
||||
decoded = json.loads(self._bytes(method, path, data, content_type if data is not None else None))
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError(f"ComfyUI returned invalid JSON for {path}") from error
|
||||
if not isinstance(decoded, dict):
|
||||
raise ValueError(f"ComfyUI returned non-object JSON for {path}")
|
||||
return decoded
|
||||
|
||||
|
||||
class RelayProvider(GenerationProvider):
|
||||
"""Submit generation to an HTTPS relay; the GPU needs no inbound connectivity."""
|
||||
name="relay"
|
||||
def __init__(self,endpoint:str,workflow:Path,token:str,*,timeout:float=1800,poll_interval:float=.8):
|
||||
from spriteforge.relay.client import RelayClient
|
||||
from threading import Lock
|
||||
self.client=RelayClient(endpoint,token);self.workflow_path=workflow.resolve();self.timeout=timeout;self.poll_interval=poll_interval;self.active={};self.lock=Lock()
|
||||
@staticmethod
|
||||
def _key(request:GenerationRequest)->str:return f"{request.project_id}/{request.asset_id}/{request.shot_id}/{request.recipe.seed}"
|
||||
def check(self)->dict:return self.client.ping_client()
|
||||
def generate(self,request:GenerationRequest,resolve_image:ImageResolver)->list[bytes]:
|
||||
require_valid_workflow(self.workflow_path);workflow_hash=self.client.upload(self.workflow_path.read_bytes())
|
||||
hashes=set(request.recipe.reference_hashes)
|
||||
hashes.update(value for value in request.recipe.controls.values() if isinstance(value,str) and re.fullmatch(r"[0-9a-f]{64}",value))
|
||||
for digest in hashes:
|
||||
uploaded=self.client.upload(resolve_image(digest))
|
||||
if uploaded!=digest:raise ValueError(f"local media hash mismatch: {digest}")
|
||||
job=self.client.submit({"protocol":1,"request":request.model_dump(mode="json"),"workflow_hash":workflow_hash})
|
||||
key=self._key(request)
|
||||
with self.lock:self.active[key]=job["id"]
|
||||
try:result=self.client.wait(job["id"],self.timeout,self.poll_interval)
|
||||
finally:
|
||||
with self.lock:self.active.pop(key,None)
|
||||
if result["status"]=="cancelled":raise ValueError("remote generation was cancelled")
|
||||
if result["status"]=="failed":raise ValueError(f"remote GPU worker failed: {result['error']}")
|
||||
return [self.client.download(digest) for digest in result["outputs"]]
|
||||
def cancel(self,request:GenerationRequest)->None:
|
||||
with self.lock:job_id=self.active.get(self._key(request))
|
||||
if job_id:
|
||||
try:self.client.cancel(job_id)
|
||||
except ValueError:pass
|
||||
29
src/spriteforge/studio/quality.py
Normal file
29
src/spriteforge/studio/quality.py
Normal file
@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from .models import CandidateMetrics
|
||||
|
||||
|
||||
def _rgba(png:bytes,size:tuple[int,int]|None=None)->np.ndarray:
|
||||
with Image.open(io.BytesIO(png)) as source:
|
||||
image=source.convert("RGBA")
|
||||
if size:image=image.resize(size,Image.Resampling.BILINEAR)
|
||||
return np.asarray(image,dtype=np.uint8)
|
||||
|
||||
|
||||
def analyze_candidate(png:bytes,neighbor_png:bytes|None=None)->CandidateMetrics:
|
||||
rgba=_rgba(png);mask=rgba[...,3]>=128;h,w=mask.shape
|
||||
if mask.any():
|
||||
ys,xs=np.nonzero(mask);bbox=((xs.max()-xs.min()+1)*(ys.max()-ys.min()+1))/(w*h)
|
||||
cx=float(xs.mean()/max(1,w-1));cy=float(ys.mean()/max(1,h-1))
|
||||
else:bbox=cx=cy=0.0
|
||||
iou=color=None
|
||||
if neighbor_png is not None:
|
||||
a=_rgba(png,(64,64));b=_rgba(neighbor_png,(64,64));ma=a[...,3]>=128;mb=b[...,3]>=128
|
||||
union=np.logical_or(ma,mb).sum();iou=float(np.logical_and(ma,mb).sum()/union) if union else 1.0
|
||||
if ma.any() and mb.any():color=float(np.linalg.norm(a[ma,:3].mean(axis=0)-b[mb,:3].mean(axis=0)))
|
||||
return CandidateMetrics(opaque_fraction=float(mask.mean()),bbox_fraction=float(bbox),centroid_x=cx,centroid_y=cy,
|
||||
neighbor_silhouette_iou=iou,neighbor_color_distance=color)
|
||||
186
src/spriteforge/studio/server.py
Normal file
186
src/spriteforge/studio/server.py
Normal file
@ -0,0 +1,186 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import webbrowser
|
||||
from collections.abc import Callable
|
||||
|
||||
from .models import Asset, GenerationRecipe, GenerationRequest, ShotGridRequest, StyleBible
|
||||
from .providers import create_provider
|
||||
from .jobs import GenerationQueue
|
||||
from .store import ProjectStore
|
||||
|
||||
_WEB = Path(__file__).with_name("web") / "index.html"
|
||||
_MAX_REQUEST = 32 * 1024 * 1024
|
||||
|
||||
|
||||
class StudioServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
|
||||
def __init__(self, address: tuple[str, int], store: ProjectStore, providers: dict[str, object] | None = None):
|
||||
self.store = store
|
||||
self.providers = providers or {}
|
||||
self.generation_queue = GenerationQueue(store, self.providers)
|
||||
super().__init__(address, StudioHandler)
|
||||
|
||||
|
||||
class StudioHandler(BaseHTTPRequestHandler):
|
||||
server: StudioServer
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
return
|
||||
|
||||
def do_GET(self) -> None:
|
||||
try:
|
||||
if self.path == "/" or self.path == "/index.html":
|
||||
self._bytes(_WEB.read_bytes(), "text/html; charset=utf-8")
|
||||
elif self.path == "/api/project":
|
||||
self._json(self.server.store.load().model_dump(mode="json"))
|
||||
elif self.path == "/api/providers":
|
||||
self._json({"providers": ["diagnostic", *sorted(self.server.providers)]})
|
||||
elif self.path == "/api/jobs":
|
||||
self._json({"jobs": [job.model_dump(mode="json") for job in self.server.store.load().jobs]})
|
||||
elif self.path == "/api/validate":
|
||||
from .validation import validate_project
|
||||
self._json({"issues":[issue.__dict__ for issue in validate_project(self.server.store)]})
|
||||
elif self.path == "/api/revisions":
|
||||
self._json({"revisions":self.server.store.revisions()})
|
||||
elif self.path.startswith("/media/"):
|
||||
relative = self.path.removeprefix("/")
|
||||
path = self.server.store.media_path(relative)
|
||||
if not path.is_file():
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
else:
|
||||
self._bytes(path.read_bytes(), "image/png", cache="public, max-age=31536000, immutable")
|
||||
else:
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
except (ValueError, OSError) as error:
|
||||
self._error(error)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
try:
|
||||
payload = self._payload()
|
||||
if self.path == "/api/style":
|
||||
project = self.server.store.update_style(StyleBible.model_validate(payload))
|
||||
self._json(project.model_dump(mode="json"))
|
||||
elif self.path == "/api/assets":
|
||||
project = self.server.store.add_asset(Asset.model_validate(payload))
|
||||
self._json(project.model_dump(mode="json"), HTTPStatus.CREATED)
|
||||
elif self.path == "/api/assets/update":
|
||||
asset_id = str(payload.pop("asset_id"))
|
||||
project = self.server.store.update_asset(asset_id, payload)
|
||||
self._json(project.model_dump(mode="json"))
|
||||
elif self.path == "/api/assets/clone":
|
||||
project=self.server.store.clone_asset(str(payload["asset_id"]),str(payload["new_id"]),str(payload["name"]))
|
||||
self._json(project.model_dump(mode="json"),HTTPStatus.CREATED)
|
||||
elif self.path == "/api/assets/delete":
|
||||
project=self.server.store.delete_asset(str(payload["asset_id"]))
|
||||
self._json(project.model_dump(mode="json"))
|
||||
elif self.path == "/api/shots":
|
||||
project, shot = self.server.store.add_shot(payload["asset_id"], payload.get("animation", "still"),
|
||||
int(payload.get("direction", 0)), int(payload.get("frame", 0)))
|
||||
self._json({"project": project.model_dump(mode="json"), "shot": shot.model_dump(mode="json")},
|
||||
HTTPStatus.CREATED)
|
||||
elif self.path == "/api/shots/grid":
|
||||
request = ShotGridRequest.model_validate(payload)
|
||||
project, shots = self.server.store.add_shot_grid(request.asset_id, request.animation,
|
||||
request.directions, request.frames)
|
||||
self._json({"project": project.model_dump(mode="json"), "created": len(shots)}, HTTPStatus.CREATED)
|
||||
elif self.path == "/api/shots/update":
|
||||
asset_id=str(payload.pop("asset_id"));shot_id=str(payload.pop("shot_id"))
|
||||
project=self.server.store.update_shot(asset_id,shot_id,payload)
|
||||
self._json(project.model_dump(mode="json"))
|
||||
elif self.path == "/api/references":
|
||||
media = self.server.store.import_data_url(payload["data_url"], "reference")
|
||||
project = self.server.store.attach_reference(payload["asset_id"], media)
|
||||
self._json(project.model_dump(mode="json"), HTTPStatus.CREATED)
|
||||
elif self.path == "/api/controls":
|
||||
role=str(payload.get("role","control"))
|
||||
media=self.server.store.import_data_url(payload["data_url"],role)
|
||||
project=self.server.store.attach_control(payload["asset_id"],payload["shot_id"],media,role)
|
||||
self._json(project.model_dump(mode="json"),HTTPStatus.CREATED)
|
||||
elif self.path == "/api/candidates/import":
|
||||
media = self.server.store.import_data_url(payload["data_url"], "candidate")
|
||||
recipe = GenerationRecipe.model_validate(payload["recipe"])
|
||||
project, candidate = self.server.store.add_candidate(payload["asset_id"], payload["shot_id"],
|
||||
media, recipe, payload.get("notes", ""))
|
||||
self._json({"project": project.model_dump(mode="json"),
|
||||
"candidate": candidate.model_dump(mode="json")}, HTTPStatus.CREATED)
|
||||
elif self.path == "/api/decisions":
|
||||
project = self.server.store.decide(payload["asset_id"], payload["shot_id"],
|
||||
payload["candidate_id"], payload["decision"])
|
||||
self._json(project.model_dump(mode="json"))
|
||||
elif self.path == "/api/candidates/update":
|
||||
asset_id=str(payload.pop("asset_id"));shot_id=str(payload.pop("shot_id"));candidate_id=str(payload.pop("candidate_id"))
|
||||
project=self.server.store.update_candidate(asset_id,shot_id,candidate_id,payload)
|
||||
self._json(project.model_dump(mode="json"))
|
||||
elif self.path == "/api/generate":
|
||||
request = GenerationRequest.model_validate(payload)
|
||||
job = self.server.generation_queue.submit(request)
|
||||
self._json({"job": job.model_dump(mode="json")}, HTTPStatus.ACCEPTED)
|
||||
elif self.path == "/api/jobs/cancel":
|
||||
job=self.server.generation_queue.cancel(str(payload["job_id"]))
|
||||
self._json({"job":job.model_dump(mode="json")})
|
||||
elif self.path == "/api/jobs/retry":
|
||||
job=self.server.generation_queue.retry(str(payload["job_id"]))
|
||||
self._json({"job":job.model_dump(mode="json")},HTTPStatus.ACCEPTED)
|
||||
elif self.path == "/api/revisions/restore":
|
||||
project=self.server.store.restore_revision(str(payload["hash"]))
|
||||
self._json(project.model_dump(mode="json"))
|
||||
elif self.path == "/api/export":
|
||||
from .export import export_project
|
||||
destination = self.server.store.root / "exports"
|
||||
files = export_project(self.server.store, destination)
|
||||
self._json({"files": [str(path) for path in files]})
|
||||
else:
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
except (KeyError, TypeError, ValueError) as error:
|
||||
self._error(error)
|
||||
|
||||
def _payload(self) -> dict:
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
except ValueError as error:
|
||||
raise ValueError("invalid Content-Length") from error
|
||||
if length <= 0 or length > _MAX_REQUEST:
|
||||
raise ValueError("request body is empty or too large")
|
||||
try:
|
||||
value = json.loads(self.rfile.read(length))
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError("invalid JSON request") from error
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("request body must be an object")
|
||||
return value
|
||||
|
||||
def _error(self, error: Exception) -> None:
|
||||
self._json({"error": str(error)}, HTTPStatus.BAD_REQUEST)
|
||||
|
||||
def _json(self, value: object, status: HTTPStatus = HTTPStatus.OK) -> None:
|
||||
self._bytes(json.dumps(value, ensure_ascii=False).encode(), "application/json; charset=utf-8", status)
|
||||
|
||||
def _bytes(self, value: bytes, content_type: str, status: HTTPStatus = HTTPStatus.OK,
|
||||
cache: str = "no-store") -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(value)))
|
||||
self.send_header("Cache-Control", cache)
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.end_headers()
|
||||
self.wfile.write(value)
|
||||
|
||||
|
||||
def run_studio(store: ProjectStore, host: str = "127.0.0.1", port: int = 8765,
|
||||
open_browser: bool = True, providers: dict[str, object] | None = None) -> None:
|
||||
server = StudioServer((host, port), store, providers)
|
||||
url = f"http://{host}:{server.server_port}/"
|
||||
print(f"SpriteForge Studio: {url}")
|
||||
if open_browser:
|
||||
threading.Timer(0.4, lambda: webbrowser.open(url)).start()
|
||||
try:
|
||||
server.serve_forever()
|
||||
finally:
|
||||
server.generation_queue.close()
|
||||
server.server_close()
|
||||
319
src/spriteforge/studio/store.py
Normal file
319
src/spriteforge/studio/store.py
Normal file
@ -0,0 +1,319 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import uuid
|
||||
from threading import RLock
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from .models import (
|
||||
Asset,
|
||||
Candidate,
|
||||
GenerationRecipe,
|
||||
GenerationJob,
|
||||
MediaRef,
|
||||
Project,
|
||||
ProjectEvent,
|
||||
Shot,
|
||||
StyleBible,
|
||||
utc_now,
|
||||
)
|
||||
from .quality import analyze_candidate
|
||||
|
||||
_ID = re.compile(r"^[a-z][a-z0-9_]*$")
|
||||
|
||||
|
||||
class ProjectStore:
|
||||
"""Transactional, content-addressed storage for one Studio project."""
|
||||
|
||||
def __init__(self, root: Path):
|
||||
self.root = root.resolve()
|
||||
self.project_path = self.root / "project.json"
|
||||
self._lock = RLock()
|
||||
|
||||
@classmethod
|
||||
def create(cls, root: Path, project_id: str, name: str) -> "ProjectStore":
|
||||
if not _ID.fullmatch(project_id):
|
||||
raise ValueError("project id must match [a-z][a-z0-9_]*")
|
||||
store = cls(root)
|
||||
if store.project_path.exists():
|
||||
raise ValueError(f"Studio project already exists: {store.project_path}")
|
||||
store.root.mkdir(parents=True, exist_ok=True)
|
||||
project = Project(id=project_id, name=name)
|
||||
project.events.append(ProjectEvent(action="project.created"))
|
||||
store.save(project)
|
||||
return store
|
||||
|
||||
def load(self) -> Project:
|
||||
try:
|
||||
return Project.model_validate_json(self.project_path.read_text(encoding="utf-8"))
|
||||
except OSError as error:
|
||||
raise ValueError(f"cannot open Studio project: {self.project_path}") from error
|
||||
|
||||
def save(self, project: Project) -> None:
|
||||
project.updated_at = utc_now()
|
||||
data = (project.model_dump_json(indent=2) + "\n").encode()
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
if self.project_path.is_file():
|
||||
previous=self.project_path.read_bytes();digest=hashlib.sha256(previous).hexdigest()
|
||||
revision=self.root/".history"/f"{digest}.json"
|
||||
if not revision.exists():revision.parent.mkdir(parents=True,exist_ok=True);revision.write_bytes(previous)
|
||||
temporary = self.project_path.with_name(f".{self.project_path.name}.{os.getpid()}.tmp")
|
||||
temporary.write_bytes(data)
|
||||
temporary.replace(self.project_path)
|
||||
|
||||
def revisions(self)->list[dict]:
|
||||
history=self.root/".history";output=[]
|
||||
if not history.is_dir():return output
|
||||
for path in history.glob("*.json"):
|
||||
try:
|
||||
project=Project.model_validate_json(path.read_text(encoding="utf-8"))
|
||||
output.append({"hash":path.stem,"updated_at":project.updated_at,"assets":len(project.assets),"events":len(project.events)})
|
||||
except (OSError,ValueError):continue
|
||||
return sorted(output,key=lambda item:item["updated_at"],reverse=True)
|
||||
|
||||
def restore_revision(self,digest:str)->Project:
|
||||
if not re.fullmatch(r"[0-9a-f]{64}",digest):raise ValueError("invalid revision hash")
|
||||
path=self.root/".history"/f"{digest}.json"
|
||||
try:project=Project.model_validate_json(path.read_text(encoding="utf-8"))
|
||||
except OSError as error:raise ValueError(f"revision not found: {digest}") from error
|
||||
project.events.append(ProjectEvent(action="project.restored"));self.save(project);return project
|
||||
|
||||
def add_asset(self, asset: Asset) -> Project:
|
||||
project = self.load()
|
||||
if any(item.id == asset.id for item in project.assets):
|
||||
raise ValueError(f"asset already exists: {asset.id}")
|
||||
project.assets.append(asset)
|
||||
project.events.append(ProjectEvent(action="asset.created", asset_id=asset.id))
|
||||
self.save(project)
|
||||
return project
|
||||
|
||||
def update_asset(self, asset_id: str, changes: dict) -> Project:
|
||||
project = self.load()
|
||||
asset = self._asset(project, asset_id)
|
||||
immutable = {"id", "references", "shots"}
|
||||
unknown = set(changes) - set(Asset.model_fields)
|
||||
if unknown:
|
||||
raise ValueError(f"unknown asset fields: {', '.join(sorted(unknown))}")
|
||||
if immutable & set(changes):
|
||||
raise ValueError("id, references and shots cannot be changed through asset settings")
|
||||
updated = asset.model_copy(update=changes)
|
||||
updated = Asset.model_validate(updated.model_dump())
|
||||
project.assets[project.assets.index(asset)] = updated
|
||||
project.events.append(ProjectEvent(action="asset.updated", asset_id=asset_id))
|
||||
self.save(project)
|
||||
return project
|
||||
|
||||
def clone_asset(self, asset_id:str, new_id:str, name:str)->Project:
|
||||
if not _ID.fullmatch(new_id):raise ValueError("asset id must match [a-z][a-z0-9_]*")
|
||||
project=self.load()
|
||||
if any(item.id==new_id for item in project.assets):raise ValueError(f"asset already exists: {new_id}")
|
||||
source=self._asset(project,asset_id);data=source.model_dump();data.update({"id":new_id,"name":name})
|
||||
data["shots"]=[{**shot.model_dump(),"id":f"shot_{uuid.uuid4().hex[:12]}","candidates":[],"approved_candidate_id":None} for shot in source.shots]
|
||||
clone=Asset.model_validate(data);project.assets.append(clone)
|
||||
project.events.append(ProjectEvent(action="asset.cloned",asset_id=new_id));self.save(project);return project
|
||||
|
||||
def delete_asset(self,asset_id:str)->Project:
|
||||
project=self.load();asset=self._asset(project,asset_id);project.assets.remove(asset)
|
||||
project.events.append(ProjectEvent(action="asset.deleted",asset_id=asset_id));self.save(project);return project
|
||||
|
||||
def update_style(self, style: StyleBible) -> Project:
|
||||
project = self.load()
|
||||
project.style = style
|
||||
project.events.append(ProjectEvent(action="style.updated"))
|
||||
self.save(project)
|
||||
return project
|
||||
|
||||
def add_shot(self, asset_id: str, animation: str, direction: int, frame: int) -> tuple[Project, Shot]:
|
||||
project = self.load()
|
||||
asset = self._asset(project, asset_id)
|
||||
shot = Shot(id=f"shot_{uuid.uuid4().hex[:12]}", animation=animation, direction=direction, frame=frame)
|
||||
asset.shots.append(shot)
|
||||
project.events.append(ProjectEvent(action="shot.created", asset_id=asset_id, shot_id=shot.id))
|
||||
self.save(project)
|
||||
return project, shot
|
||||
|
||||
def add_shot_grid(self, asset_id: str, animation: str, directions: int, frames: int) -> tuple[Project, list[Shot]]:
|
||||
project = self.load()
|
||||
asset = self._asset(project, asset_id)
|
||||
existing = {(shot.animation, shot.direction, shot.frame) for shot in asset.shots}
|
||||
created = []
|
||||
for direction in range(directions):
|
||||
for frame in range(frames):
|
||||
key = (animation, direction, frame)
|
||||
if key in existing:
|
||||
continue
|
||||
shot = Shot(id=f"shot_{uuid.uuid4().hex[:12]}", animation=animation,
|
||||
direction=direction, frame=frame)
|
||||
asset.shots.append(shot); created.append(shot)
|
||||
asset.directions = max(asset.directions, directions)
|
||||
project.events.append(ProjectEvent(action="shot_grid.created", asset_id=asset_id))
|
||||
self.save(project)
|
||||
return project, created
|
||||
|
||||
def update_shot(self, asset_id: str, shot_id: str, changes: dict) -> Project:
|
||||
allowed={"prompt_override","negative_prompt_override"}
|
||||
if set(changes)-allowed:raise ValueError("only shot prompt overrides are editable")
|
||||
project=self.load();shot=self._shot(self._asset(project,asset_id),shot_id)
|
||||
updated=Shot.model_validate(shot.model_copy(update=changes).model_dump())
|
||||
asset=self._asset(project,asset_id);asset.shots[asset.shots.index(shot)]=updated
|
||||
project.events.append(ProjectEvent(action="shot.updated",asset_id=asset_id,shot_id=shot_id));self.save(project);return project
|
||||
|
||||
def import_png(self, png: bytes, role: str) -> MediaRef:
|
||||
try:
|
||||
with Image.open(io.BytesIO(png)) as image:
|
||||
image.load()
|
||||
rgba = image.convert("RGBA")
|
||||
width, height = rgba.size
|
||||
output = io.BytesIO()
|
||||
rgba.save(output, format="PNG", optimize=True)
|
||||
canonical = output.getvalue()
|
||||
except (OSError, ValueError) as error:
|
||||
raise ValueError("input is not a valid image") from error
|
||||
digest = hashlib.sha256(canonical).hexdigest()
|
||||
relative = Path("media") / digest[:2] / digest[2:4] / f"{digest}.png"
|
||||
destination = self.root / relative
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.write_bytes(canonical)
|
||||
return MediaRef(hash=digest, path=relative.as_posix(), width=width, height=height, role=role)
|
||||
|
||||
def import_data_url(self, value: str, role: str) -> MediaRef:
|
||||
match = re.fullmatch(r"data:image/(?:png|jpeg|webp);base64,(.+)", value, re.DOTALL)
|
||||
if not match:
|
||||
raise ValueError("expected a PNG, JPEG or WebP data URL")
|
||||
try:
|
||||
raw = base64.b64decode(match.group(1), validate=True)
|
||||
except ValueError as error:
|
||||
raise ValueError("invalid base64 image") from error
|
||||
return self.import_png(raw, role)
|
||||
|
||||
def attach_reference(self, asset_id: str, media: MediaRef) -> Project:
|
||||
project = self.load()
|
||||
asset = self._asset(project, asset_id)
|
||||
asset.references.append(media.model_copy(update={"role": "reference"}))
|
||||
project.events.append(ProjectEvent(action="reference.added", asset_id=asset_id))
|
||||
self.save(project)
|
||||
return project
|
||||
|
||||
def attach_control(self, asset_id: str, shot_id: str, media: MediaRef, role: str = "control") -> Project:
|
||||
if role not in {"control", "mask"}:
|
||||
raise ValueError("shot media role must be control or mask")
|
||||
project = self.load();shot=self._shot(self._asset(project,asset_id),shot_id)
|
||||
shot.controls=[item for item in shot.controls if item.role!=role]
|
||||
shot.controls.append(media.model_copy(update={"role":role}))
|
||||
project.events.append(ProjectEvent(action=f"shot.{role}.updated",asset_id=asset_id,shot_id=shot_id))
|
||||
self.save(project);return project
|
||||
|
||||
def add_candidate(self, asset_id: str, shot_id: str, media: MediaRef,
|
||||
recipe: GenerationRecipe, notes: str = "") -> tuple[Project, Candidate]:
|
||||
project = self.load()
|
||||
asset=self._asset(project, asset_id);shot = self._shot(asset, shot_id)
|
||||
neighbors=[]
|
||||
for other in asset.shots:
|
||||
if other.animation!=shot.animation or not other.approved_candidate_id:continue
|
||||
approved=next((item for item in other.candidates if item.id==other.approved_candidate_id),None)
|
||||
if approved:neighbors.append((abs(other.direction-shot.direction)*1000+abs(other.frame-shot.frame),approved))
|
||||
neighbor_png=None
|
||||
if neighbors:
|
||||
approved=min(neighbors,key=lambda item:item[0])[1];neighbor_png=self.media_path(approved.image.path).read_bytes()
|
||||
metrics=analyze_candidate(self.media_path(media.path).read_bytes(),neighbor_png)
|
||||
candidate = Candidate(id=f"candidate_{uuid.uuid4().hex[:12]}", image=media.model_copy(update={"role": "candidate"}),
|
||||
recipe=recipe, notes=notes,metrics=metrics)
|
||||
shot.candidates.append(candidate)
|
||||
project.events.append(ProjectEvent(action="candidate.added", asset_id=asset_id,
|
||||
shot_id=shot_id, candidate_id=candidate.id))
|
||||
self.save(project)
|
||||
return project, candidate
|
||||
|
||||
def decide(self, asset_id: str, shot_id: str, candidate_id: str, decision: str) -> Project:
|
||||
if decision not in {"approved", "rejected", "pending"}:
|
||||
raise ValueError("decision must be approved, rejected or pending")
|
||||
project = self.load()
|
||||
shot = self._shot(self._asset(project, asset_id), shot_id)
|
||||
candidate = next((item for item in shot.candidates if item.id == candidate_id), None)
|
||||
if candidate is None:
|
||||
raise ValueError(f"candidate not found: {candidate_id}")
|
||||
if decision == "approved":
|
||||
for item in shot.candidates:
|
||||
if item.decision == "approved":
|
||||
item.decision = "pending"
|
||||
shot.approved_candidate_id = candidate_id
|
||||
elif shot.approved_candidate_id == candidate_id:
|
||||
shot.approved_candidate_id = None
|
||||
candidate.decision = decision
|
||||
project.events.append(ProjectEvent(action=f"candidate.{decision}", asset_id=asset_id,
|
||||
shot_id=shot_id, candidate_id=candidate_id))
|
||||
self.save(project)
|
||||
return project
|
||||
|
||||
def update_candidate(self, asset_id: str, shot_id: str, candidate_id: str, changes: dict) -> Project:
|
||||
allowed={"notes","process"}
|
||||
if set(changes)-allowed:raise ValueError("only candidate notes and processing are editable")
|
||||
project=self.load();shot=self._shot(self._asset(project,asset_id),shot_id)
|
||||
candidate=next((item for item in shot.candidates if item.id==candidate_id),None)
|
||||
if candidate is None:raise ValueError(f"candidate not found: {candidate_id}")
|
||||
data=candidate.model_dump();data.update(changes);updated=Candidate.model_validate(data)
|
||||
shot.candidates[shot.candidates.index(candidate)]=updated
|
||||
project.events.append(ProjectEvent(action="candidate.updated",asset_id=asset_id,shot_id=shot_id,candidate_id=candidate_id));self.save(project);return project
|
||||
|
||||
def media_path(self, relative: str) -> Path:
|
||||
candidate = (self.root / relative).resolve()
|
||||
if candidate != self.root and self.root not in candidate.parents:
|
||||
raise ValueError("media path escapes the Studio project")
|
||||
return candidate
|
||||
|
||||
def media_bytes_by_hash(self, digest: str) -> bytes:
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", digest):
|
||||
raise ValueError("invalid media hash")
|
||||
path = self.root / "media" / digest[:2] / digest[2:4] / f"{digest}.png"
|
||||
if not path.is_file():
|
||||
raise ValueError(f"media not found: {digest}")
|
||||
return path.read_bytes()
|
||||
|
||||
def create_job(self, request) -> GenerationJob:
|
||||
with self._lock:
|
||||
project = self.load()
|
||||
job = GenerationJob(id=f"job_{uuid.uuid4().hex[:12]}", request=request)
|
||||
project.jobs.append(job)
|
||||
project.events.append(ProjectEvent(action="generation.queued", asset_id=request.asset_id, shot_id=request.shot_id))
|
||||
self.save(project)
|
||||
return job
|
||||
|
||||
def update_job(self, job_id: str, **changes) -> GenerationJob:
|
||||
with self._lock:
|
||||
project = self.load()
|
||||
job = next((item for item in project.jobs if item.id == job_id), None)
|
||||
if job is None:
|
||||
raise ValueError(f"generation job not found: {job_id}")
|
||||
updated = GenerationJob.model_validate(job.model_copy(update=changes).model_dump())
|
||||
project.jobs[project.jobs.index(job)] = updated
|
||||
project.events.append(ProjectEvent(action=f"generation.{updated.status}", asset_id=updated.request.asset_id,
|
||||
shot_id=updated.request.shot_id))
|
||||
self.save(project)
|
||||
return updated
|
||||
|
||||
def get_job(self, job_id: str) -> GenerationJob:
|
||||
job=next((item for item in self.load().jobs if item.id==job_id),None)
|
||||
if job is None:raise ValueError(f"generation job not found: {job_id}")
|
||||
return job
|
||||
|
||||
@staticmethod
|
||||
def _asset(project: Project, asset_id: str) -> Asset:
|
||||
asset = next((item for item in project.assets if item.id == asset_id), None)
|
||||
if asset is None:
|
||||
raise ValueError(f"asset not found: {asset_id}")
|
||||
return asset
|
||||
|
||||
@staticmethod
|
||||
def _shot(asset: Asset, shot_id: str) -> Shot:
|
||||
shot = next((item for item in asset.shots if item.id == shot_id), None)
|
||||
if shot is None:
|
||||
raise ValueError(f"shot not found: {shot_id}")
|
||||
return shot
|
||||
63
src/spriteforge/studio/validation.py
Normal file
63
src/spriteforge/studio/validation.py
Normal file
@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .models import Project
|
||||
from .store import ProjectStore
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StudioIssue:
|
||||
severity:str
|
||||
location:str
|
||||
message:str
|
||||
|
||||
def __str__(self)->str:return f"{self.severity.upper()}\t{self.location}\t{self.message}"
|
||||
|
||||
|
||||
def validate_project(store:ProjectStore)->list[StudioIssue]:
|
||||
project=store.load();issues=[]
|
||||
def add(severity,location,message):issues.append(StudioIssue(severity,location,message))
|
||||
if not project.style.description:add("warning","style","visual identity is empty")
|
||||
groups={}
|
||||
for asset in project.assets:
|
||||
where=asset.id
|
||||
if not asset.references:add("warning",where,"no canonical appearance reference")
|
||||
if asset.generation_width<asset.target_width or asset.generation_height<asset.target_height:add("warning",where,"generation canvas is smaller than target box")
|
||||
if not asset.shots:add("warning",where,"no animation shots")
|
||||
coords=[(s.animation,s.direction,s.frame) for s in asset.shots]
|
||||
if len(coords)!=len(set(coords)):add("error",where,"duplicate animation/direction/frame shots")
|
||||
for animation in dict.fromkeys(s.animation for s in asset.shots):
|
||||
shots=[s for s in asset.shots if s.animation==animation]
|
||||
if not shots:continue
|
||||
max_frame=max(s.frame for s in shots)
|
||||
expected={(d,f) for d in range(asset.directions) for f in range(max_frame+1)}
|
||||
actual={(s.direction,s.frame) for s in shots}
|
||||
missing=expected-actual
|
||||
if missing:add("error",f"{where}/{animation}",f"missing {len(missing)} shot(s) from rectangular grid")
|
||||
for shot in asset.shots:
|
||||
loc=f"{where}/{shot.animation}/d{shot.direction}/f{shot.frame}"
|
||||
approved=[c for c in shot.candidates if c.id==shot.approved_candidate_id]
|
||||
if not approved:add("error",loc,"no approved candidate")
|
||||
for candidate in shot.candidates:
|
||||
path=store.media_path(candidate.image.path)
|
||||
if not path.is_file():add("error",loc,f"candidate media missing: {candidate.image.hash}")
|
||||
if candidate.metrics and candidate.metrics.opaque_fraction==0:add("error",loc,f"candidate {candidate.id} is fully transparent")
|
||||
if candidate.metrics and candidate.metrics.neighbor_silhouette_iou is not None and candidate.metrics.neighbor_silhouette_iou<.2:add("warning",loc,f"candidate {candidate.id} silhouette differs strongly from neighboring approved frame")
|
||||
if asset.layer_group:groups.setdefault(asset.layer_group,[]).append(asset)
|
||||
for name,members in groups.items():
|
||||
base=members[0];base_coords={(s.animation,s.direction,s.frame) for s in base.shots}
|
||||
for member in members[1:]:
|
||||
if {(s.animation,s.direction,s.frame) for s in member.shots}!=base_coords:add("error",f"layers/{name}",f"shot grids differ: {base.id} vs {member.id}")
|
||||
if (member.target_width,member.target_height,member.pivot_x,member.pivot_y)!=(base.target_width,base.target_height,base.pivot_x,base.pivot_y):add("error",f"layers/{name}",f"box or pivot differs: {base.id} vs {member.id}")
|
||||
for job in project.jobs:
|
||||
if job.status=="failed":add("warning",f"jobs/{job.id}",job.error or "generation failed")
|
||||
if job.status in {"queued","running"}:add("info",f"jobs/{job.id}",job.status)
|
||||
return issues
|
||||
|
||||
|
||||
def validation_text(store:ProjectStore)->str:
|
||||
issues=validate_project(store);errors=sum(x.severity=="error" for x in issues);warnings=sum(x.severity=="warning" for x in issues)
|
||||
body="\n".join(map(str,issues))
|
||||
return (body+"\n" if body else "")+f"validation: {errors} error(s), {warnings} warning(s)"
|
||||
57
src/spriteforge/studio/web/index.html
Normal file
57
src/spriteforge/studio/web/index.html
Normal file
@ -0,0 +1,57 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>SpriteForge Studio</title>
|
||||
<style>
|
||||
:root{color-scheme:dark;--bg:#090c0d;--panel:#111719;--line:#283235;--ink:#d9dfdc;--muted:#7f918b;--acid:#a8ca42;--blood:#b13b42}
|
||||
*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 60% -20%,#26312d 0,#090c0d 38%);color:var(--ink);font:14px/1.4 Inter,Segoe UI,sans-serif}
|
||||
button,input,textarea,select{font:inherit;color:inherit;background:#0b1011;border:1px solid var(--line);border-radius:4px;padding:8px}button{cursor:pointer}button:hover{border-color:var(--acid)}button.primary{background:var(--acid);color:#111;border:0;font-weight:700}.danger{color:#ef8589}.muted{color:var(--muted)}
|
||||
header{height:58px;display:flex;align-items:center;padding:0 20px;border-bottom:1px solid var(--line);background:#090d0ed9;position:sticky;top:0;z-index:2}header b{letter-spacing:.16em}header span{margin-left:auto;color:var(--muted)}
|
||||
main{display:grid;grid-template-columns:270px 1fr;min-height:calc(100vh - 58px)}aside{border-right:1px solid var(--line);padding:16px;background:#0d1213}section{padding:20px;max-width:1500px}.card{border:1px solid var(--line);background:var(--panel);border-radius:6px;padding:14px;margin-bottom:14px}.row{display:flex;gap:8px;align-items:center}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:12px}.asset{width:100%;text-align:left;margin:4px 0;border-color:transparent}.asset.active{border-color:var(--acid);background:#17201b}.field{display:grid;gap:5px;margin:8px 0}.field textarea{min-height:70px;resize:vertical}.field input,.field textarea,.field select{width:100%}.candidate{position:relative;background:#080b0c;border:1px solid var(--line);padding:8px}.candidate.approved{border:2px solid var(--acid)}.candidate.rejected{opacity:.42}.candidate img,.reference img{width:100%;aspect-ratio:1;object-fit:contain;background:repeating-conic-gradient(#161c1d 0 25%,#101516 0 50%) 0/16px 16px;image-rendering:auto}.candidate small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.badge{padding:2px 6px;border-radius:10px;background:#25302b;font-size:11px}.empty{padding:36px;text-align:center;color:var(--muted);border:1px dashed var(--line)}dialog{background:#101617;color:var(--ink);border:1px solid var(--line);border-radius:8px;min-width:min(520px,90vw)}dialog::backdrop{background:#000a}h1,h2,h3{margin:0 0 12px}h1{font-size:20px}h2{font-size:17px}h3{font-size:14px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}#toast{position:fixed;right:18px;bottom:18px;background:#34171a;border:1px solid var(--blood);padding:10px;display:none}
|
||||
@media(max-width:760px){main{grid-template-columns:1fr}aside{border-right:0;border-bottom:1px solid var(--line)}}
|
||||
</style>
|
||||
</head>
|
||||
<body><header><b>SPRITEFORGE // STUDIO</b><span id="projectName">loading…</span></header>
|
||||
<main><aside><button class="primary" style="width:100%" onclick="showCreateAsset()">+ New asset</button><button style="width:100%;margin-top:8px" onclick="validateProject()">Validate project</button><button style="width:100%;margin-top:8px" onclick="showJobs()">Generation jobs</button><button style="width:100%;margin-top:8px" onclick="showRevisions()">Revision history</button><button style="width:100%;margin-top:8px" onclick="exportProject()">Export approved</button><div id="assets"></div></aside><section id="workspace"></section></main>
|
||||
<dialog id="assetDialog"><form method="dialog" onsubmit="createAsset(event)"><h2>Create asset</h2><label class="field">ID<input name="id" pattern="[a-z][a-z0-9_]*" required placeholder="field_agent"></label><label class="field">Name<input name="name" required></label><label class="field">Kind<select name="kind"><option>character</option><option>monster</option><option>environment</option><option>item</option><option>effect</option><option>other</option></select></label><div class="row"><label class="field">Target width<input name="target_width" type="number" value="60" min="1" max="4096"></label><label class="field">Target height<input name="target_height" type="number" value="60" min="1" max="4096"></label></div><div class="row"><button value="cancel">Cancel</button><button class="primary" value="default">Create</button></div></form></dialog>
|
||||
<dialog id="maskDialog"><h2>Inpainting mask</h2><p class="muted">Paint white where the generator may change the image. Right mouse button erases.</p><canvas id="maskCanvas" width="512" height="512" style="max-width:70vw;max-height:65vh;background:#000;touch-action:none"></canvas><div class="row"><label>Brush <input id="brush" type="range" min="2" max="100" value="28"></label><button onclick="clearMask()">Clear</button><button onclick="$('#maskDialog').close()">Cancel</button><button class="primary" onclick="saveMask()">Save mask</button></div></dialog>
|
||||
<dialog id="previewDialog"><h2>Approved animation preview</h2><canvas id="previewCanvas" width="256" height="256" style="min-width:256px;min-height:256px;max-width:70vw;max-height:70vh;background:#080b0c;image-rendering:pixelated"></canvas><p id="previewLabel" class="muted"></p><button onclick="stopPreview()">Close</button></dialog>
|
||||
<div id="toast"></div><input id="file" type="file" accept="image/*" hidden>
|
||||
<script>
|
||||
let project,selected=null,availableProviders=['diagnostic'];const $=s=>document.querySelector(s);
|
||||
async function api(path,body){const r=await fetch(path,{method:body?'POST':'GET',headers:body?{'Content-Type':'application/json'}:{},body:body?JSON.stringify(body):null});const x=await r.json();if(!r.ok)throw Error(x.error||r.statusText);return x}
|
||||
function esc(s=''){return String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
|
||||
function toast(e){const t=$('#toast');t.textContent=e.message||e;t.style.display='block';setTimeout(()=>t.style.display='none',4500)}
|
||||
async function load(){project=await api('/api/project');availableProviders=(await api('/api/providers')).providers;$('#projectName').textContent=project.name;render()}
|
||||
function render(){const list=$('#assets');list.innerHTML='<h3 style="margin-top:20px">Library</h3>'+project.assets.map(a=>`<button class="asset ${a.id===selected?'active':''}" onclick="selectAsset('${a.id}')"><b>${esc(a.name)}</b><br><small class="muted">${a.kind} · ${a.target_width}×${a.target_height}</small></button>`).join('');const asset=project.assets.find(a=>a.id===selected);$('#workspace').innerHTML=asset?assetView(asset):welcomeView()}
|
||||
function welcomeView(){return `<div class="card"><h1>${esc(project.name)}</h1><p class="muted">Build a coherent visual library through references, generated candidates and explicit approval.</p></div><div class="card"><h3>Style bible</h3>${styleFields()}</div>`}
|
||||
function styleFields(){const s=project.style;return `<label class="field">Visual identity<textarea id="description">${esc(s.description)}</textarea></label><label class="field">Global positive prompt<textarea id="positive">${esc(s.positive_prompt)}</textarea></label><label class="field">Global negative prompt<textarea id="negative">${esc(s.negative_prompt)}</textarea></label><div class="row"><label class="field" style="flex:1">Camera<input id="camera" value="${esc(s.camera)}"></label><label class="field" style="flex:1">Lighting<input id="lighting" value="${esc(s.lighting)}"></label></div><label class="field">Palette notes<input id="palette" value="${esc(s.palette_notes)}"></label><button class="primary" onclick="saveStyle()">Save style bible</button>`}
|
||||
function assetView(a){const anims=[...new Set(a.shots.map(s=>s.animation))];return `<div class="card"><div class="row"><div><h1>${esc(a.name)}</h1><span class="badge">${a.kind}</span> <span class="badge">${a.target_width}×${a.target_height}</span></div><button style="margin-left:auto" onclick="cloneAsset('${a.id}')">Clone structure</button><button class="danger" onclick="deleteAsset('${a.id}')">Delete</button><button onclick="uploadReference('${a.id}')">+ Reference</button><button onclick="addGrid('${a.id}')">+ Animation grid</button></div><div class="row"><label class="field" style="flex:1">Name<input id="editName" value="${esc(a.name)}"></label><label class="field">Target W<input id="targetW" type="number" value="${a.target_width}"></label><label class="field">Target H<input id="targetH" type="number" value="${a.target_height}"></label><label class="field">FPS<input id="fps" type="number" step=".1" value="${a.fps}"></label></div><div class="row"><label class="field">Generation W<input id="genW" type="number" value="${a.generation_width}"></label><label class="field">Generation H<input id="genH" type="number" value="${a.generation_height}"></label><label class="field">Pivot X (0–1)<input id="pivotX" type="number" min="0" max="1" step=".01" value="${a.pivot_x}"></label><label class="field">Pivot Y (0–1)<input id="pivotY" type="number" min="0" max="1" step=".01" value="${a.pivot_y}"></label><label class="field">Layer group<input id="layerGroup" value="${esc(a.layer_group)}" placeholder="agent_body"></label><label class="field">Order<input id="layerOrder" type="number" value="${a.layer_order}"></label></div><label class="field">Asset prompt<textarea id="assetPrompt">${esc(a.prompt)}</textarea></label><label class="field">Asset negative prompt<textarea id="assetNegative">${esc(a.negative_prompt)}</textarea></label><button class="primary" onclick="saveAsset('${a.id}')">Save asset settings</button></div><div class="card"><h3>Approved preview</h3><div class="row"><select id="previewAnim">${anims.map(x=>`<option>${esc(x)}</option>`).join('')}</select><input id="previewDir" type="number" min="0" max="${a.directions-1}" value="0"><button onclick="playPreview('${a.id}')">Play${a.layer_group?' layered':''}</button></div></div><div class="card"><h3>Generation batch</h3><div class="row"><label class="field">Provider<select id="provider">${availableProviders.map(p=>`<option ${p==='comfyui'?'selected':''}>${p}</option>`).join('')}</select></label><label class="field">Model<input id="model" value="sd15-controlled"></label><label class="field">Seed<input id="seed" type="number" value="1000"></label><label class="field">Candidates<input id="count" type="number" min="1" max="64" value="4"></label><label class="field">Steps<input id="steps" type="number" value="28"></label><label class="field">CFG<input id="cfg" type="number" step=".1" value="6.5"></label><label class="field">Denoise<input id="denoise" type="number" min="0" max="1" step=".05" value="1"></label></div><div class="row"><label class="field">Sampler<input id="sampler" value="dpmpp_2m"></label><label class="field">Scheduler<input id="scheduler" value="karras"></label><button class="primary" onclick="generatePending('${a.id}')">Generate all empty shots</button></div></div><div class="card"><h3>References</h3><div class="grid">${a.references.length?a.references.map(r=>`<div class="reference"><img src="/${r.path}"><small>${r.width}×${r.height}</small></div>`).join(''):'<div class="empty">Add a canonical appearance reference.</div>'}</div></div><h2>Shots</h2>${a.shots.length?a.shots.map(s=>shotView(a,s)).join(''):'<div class="empty">Create an animation grid.</div>'}`}
|
||||
function shotView(a,s){return `<div class="card"><div class="row"><h3 style="margin:0">${esc(s.animation)} · dir ${s.direction} · frame ${s.frame}</h3>${s.controls.map(c=>`<span class="badge">${c.role}</span>`).join('')}<button style="margin-left:auto" onclick="generateOne('${a.id}','${s.id}')">Generate</button><button onclick="editShot('${a.id}','${s.id}')">Prompt override</button><button onclick="generateTest('${a.id}','${s.id}')">Pipeline test</button><button onclick="uploadControl('${a.id}','${s.id}')">+ Pose/depth</button><button onclick="uploadCandidate('${a.id}','${s.id}')">Import</button></div><div class="grid" style="margin-top:12px">${s.candidates.length?s.candidates.map(c=>candidateView(a,s,c)).join(''):'<div class="empty">No candidates yet.</div>'}</div></div>`}
|
||||
function candidateView(a,s,c){const m=c.metrics;return `<div class="candidate ${c.decision}"><img src="/${c.image.path}"><small>${esc(c.recipe.provider)} / ${esc(c.recipe.model)}</small><small class="muted">seed ${c.recipe.seed} · α ${c.process.alpha_threshold}${c.process.chroma_key?' · key '+c.process.chroma_key:''}</small>${m?`<small class="muted">coverage ${(m.opaque_fraction*100).toFixed(0)}%${m.neighbor_silhouette_iou==null?'':' · temporal IoU '+m.neighbor_silhouette_iou.toFixed(2)}</small>`:''}<div class="row" style="margin-top:7px"><button class="primary" onclick="decide('${a.id}','${s.id}','${c.id}','approved')">Approve</button><button onclick="processCandidate('${a.id}','${s.id}','${c.id}')">Process</button><button onclick="openMask('${a.id}','${s.id}',${c.image.width},${c.image.height})">Mask</button><button class="danger" onclick="decide('${a.id}','${s.id}','${c.id}','rejected')">Reject</button></div></div>`}
|
||||
function selectAsset(id){selected=id;render()}function showCreateAsset(){$('#assetDialog').showModal()}
|
||||
async function cloneAsset(asset_id){const new_id=prompt('New asset id');if(!new_id)return;const name=prompt('New asset name',new_id);if(!name)return;try{project=await api('/api/assets/clone',{asset_id,new_id,name});selected=new_id;render()}catch(x){toast(x)}}async function deleteAsset(asset_id){if(!confirm('Delete this asset? It remains recoverable in Revision history.'))return;try{project=await api('/api/assets/delete',{asset_id});selected=null;render()}catch(x){toast(x)}}
|
||||
async function createAsset(e){e.preventDefault();const f=new FormData(e.target);try{project=await api('/api/assets',{id:f.get('id'),name:f.get('name'),kind:f.get('kind'),target_width:+f.get('target_width'),target_height:+f.get('target_height')});selected=f.get('id');$('#assetDialog').close();render()}catch(x){toast(x)}}
|
||||
async function saveStyle(){try{project=await api('/api/style',{description:$('#description').value,positive_prompt:$('#positive').value,negative_prompt:$('#negative').value,camera:$('#camera').value,lighting:$('#lighting').value,palette_notes:$('#palette').value});render()}catch(x){toast(x)}}
|
||||
async function pick(){return new Promise((ok,no)=>{const f=$('#file');f.value='';f.onchange=()=>{const r=new FileReader;r.onload=()=>ok(r.result);r.onerror=no;r.readAsDataURL(f.files[0])};f.click()})}
|
||||
async function uploadReference(asset_id){try{project=await api('/api/references',{asset_id,data_url:await pick()});render()}catch(x){toast(x)}}
|
||||
async function uploadControl(asset_id,shot_id){try{project=await api('/api/controls',{asset_id,shot_id,role:'control',data_url:await pick()});render()}catch(x){toast(x)}}
|
||||
async function addShot(asset_id){const animation=prompt('Animation','idle');if(animation===null)return;const direction=+(prompt('Direction index','0')||0),frame=+(prompt('Frame index','0')||0);try{const x=await api('/api/shots',{asset_id,animation,direction,frame});project=x.project;render()}catch(x){toast(x)}}
|
||||
async function addGrid(asset_id){const animation=prompt('Animation id','idle');if(!animation)return;const directions=+(prompt('Directions','8')||8),frames=+(prompt('Frames per direction','4')||4);try{const x=await api('/api/shots/grid',{asset_id,animation,directions,frames});project=x.project;render()}catch(x){toast(x)}}
|
||||
async function saveAsset(asset_id){try{project=await api('/api/assets/update',{asset_id,name:$('#editName').value,target_width:+$('#targetW').value,target_height:+$('#targetH').value,generation_width:+$('#genW').value,generation_height:+$('#genH').value,fps:+$('#fps').value,pivot_x:+$('#pivotX').value,pivot_y:+$('#pivotY').value,layer_group:$('#layerGroup').value,layer_order:+$('#layerOrder').value,prompt:$('#assetPrompt').value,negative_prompt:$('#assetNegative').value});render()}catch(x){toast(x)}}
|
||||
async function editShot(asset_id,shot_id){const a=project.assets.find(x=>x.id===asset_id),s=a.shots.find(x=>x.id===shot_id),positive=prompt('Additional prompt for this frame',s.prompt_override);if(positive===null)return;const negative=prompt('Additional negative prompt',s.negative_prompt_override);if(negative===null)return;try{project=await api('/api/shots/update',{asset_id,shot_id,prompt_override:positive,negative_prompt_override:negative});render()}catch(x){toast(x)}}
|
||||
async function uploadCandidate(asset_id,shot_id){try{const data_url=await pick(),a=project.assets.find(x=>x.id===asset_id);const x=await api('/api/candidates/import',{asset_id,shot_id,data_url,recipe:{provider:'manual_import',model:'human',seed:0,prompt:a.prompt,width:Math.max(256,a.target_width),height:Math.max(256,a.target_height),reference_hashes:a.references.map(r=>r.hash)}});project=x.project;render()}catch(x){toast(x)}}
|
||||
async function decide(asset_id,shot_id,candidate_id,decision){try{project=await api('/api/decisions',{asset_id,shot_id,candidate_id,decision});render()}catch(x){toast(x)}}
|
||||
async function processCandidate(asset_id,shot_id,candidate_id){const a=project.assets.find(x=>x.id===asset_id),s=a.shots.find(x=>x.id===shot_id),c=s.candidates.find(x=>x.id===candidate_id),alpha=prompt('Alpha threshold 0..255',c.process.alpha_threshold);if(alpha===null)return;const key=prompt('Chroma key #RRGGBB or empty',c.process.chroma_key);if(key===null)return;const tolerance=key?(+(prompt('Chroma tolerance 0..441',c.process.chroma_tolerance||32)||0)):0;try{project=await api('/api/candidates/update',{asset_id,shot_id,candidate_id,process:{alpha_threshold:+alpha,chroma_key:key,chroma_tolerance:tolerance}});render()}catch(x){toast(x)}}
|
||||
async function generateTest(asset_id,shot_id){try{const a=project.assets.find(x=>x.id===asset_id),s=a.shots.find(x=>x.id===shot_id),controls=Object.fromEntries(s.controls.map(c=>[c.role,c.hash]));await api('/api/generate',{project_id:project.id,asset_id,shot_id,count:4,recipe:{provider:'diagnostic',model:'pipeline_test',seed:1,prompt:[project.style.positive_prompt,a.prompt,s.prompt_override].filter(Boolean).join(', '),negative_prompt:[project.style.negative_prompt,a.negative_prompt,s.negative_prompt_override].filter(Boolean).join(', '),width:a.generation_width,height:a.generation_height,reference_hashes:a.references.map(r=>r.hash),controls}});pollJobs()}catch(x){toast(x)}}
|
||||
async function pollJobs(){for(let i=0;i<200;i++){await new Promise(r=>setTimeout(r,150));const x=await api('/api/jobs'),active=x.jobs.some(j=>j.status==='queued'||j.status==='running');if(!active){project=await api('/api/project');render();const failed=x.jobs.filter(j=>j.status==='failed').at(-1);if(failed)toast(failed.error);return}}}
|
||||
function requestFor(a,s,seed){const controls=Object.fromEntries(s.controls.map(c=>[c.role,c.hash])),previous=a.shots.find(x=>x.animation===s.animation&&x.direction===s.direction&&x.frame===s.frame-1&&x.approved_candidate_id);if(previous){const c=previous.candidates.find(x=>x.id===previous.approved_candidate_id);if(c)controls.previous=c.image.hash}return {project_id:project.id,asset_id:a.id,shot_id:s.id,count:+($('#count')?.value||4),recipe:{provider:$('#provider')?.value||'diagnostic',model:$('#model')?.value||'default',seed,prompt:[project.style.positive_prompt,a.prompt,s.prompt_override].filter(Boolean).join(', '),negative_prompt:[project.style.negative_prompt,a.negative_prompt,s.negative_prompt_override].filter(Boolean).join(', '),width:a.generation_width,height:a.generation_height,reference_hashes:a.references.map(r=>r.hash),controls,steps:+($('#steps')?.value||28),cfg:+($('#cfg')?.value||6.5),denoise:+($('#denoise')?.value??1),sampler:$('#sampler')?.value||'dpmpp_2m',scheduler:$('#scheduler')?.value||'karras'}}}async function generateOne(asset_id,shot_id){try{const a=project.assets.find(x=>x.id===asset_id),s=a.shots.find(x=>x.id===shot_id);await api('/api/generate',requestFor(a,s,+($('#seed')?.value||1)+s.direction*1000+s.frame);pollJobs()}catch(x){toast(x)}}async function generatePending(asset_id){try{const a=project.assets.find(x=>x.id===asset_id),base=+$('#seed').value;for(const s of a.shots.filter(x=>!x.approved_candidate_id)){await api('/api/generate',requestFor(a,s,base+s.direction*1000+s.frame))}pollJobs()}catch(x){toast(x)}}
|
||||
async function exportProject(){try{const x=await api('/api/export',{});alert('Exported:\n'+x.files.join('\n'))}catch(x){toast(x)}}
|
||||
async function validateProject(){try{const x=await api('/api/validate'),lines=x.issues.map(i=>`${i.severity.toUpperCase()} ${i.location} ${i.message}`);alert(lines.join('\n')||'No issues')}catch(x){toast(x)}}async function showJobs(){try{const x=await api('/api/jobs'),lines=x.jobs.slice(-30).map(j=>`${j.status.toUpperCase()} ${j.id} ${j.request.asset_id}/${j.request.shot_id}${j.error?' '+j.error:''}`);alert(lines.join('\n')||'No jobs')}catch(x){toast(x)}}
|
||||
async function showRevisions(){try{const x=await api('/api/revisions');if(!x.revisions.length)return alert('No revisions yet');const lines=x.revisions.slice(0,30).map((r,i)=>`${i+1}. ${r.updated_at} · ${r.assets} assets · ${r.hash.slice(0,12)}`);const choice=prompt('Enter revision number to restore, or Cancel:\n'+lines.join('\n'));if(!choice)return;const r=x.revisions[+choice-1];if(!r)throw Error('Invalid revision number');if(!confirm('Restore '+r.updated_at+'? Current state will remain in history.'))return;project=await api('/api/revisions/restore',{hash:r.hash});selected=null;render()}catch(x){toast(x)}}
|
||||
let maskTarget=null,painting=false;function openMask(asset_id,shot_id,w,h){maskTarget={asset_id,shot_id};const c=$('#maskCanvas');c.width=w;c.height=h;clearMask();$('#maskDialog').showModal()}function clearMask(){const c=$('#maskCanvas'),x=c.getContext('2d');x.fillStyle='#000';x.fillRect(0,0,c.width,c.height)}
|
||||
let previewTimer=null;async function playPreview(asset_id){stopPreview(false);const a=project.assets.find(x=>x.id===asset_id),animation=$('#previewAnim').value,direction=+$('#previewDir').value,layers=(a.layer_group?project.assets.filter(x=>x.layer_group===a.layer_group):[a]).sort((x,y)=>x.layer_order-y.layer_order),frames=[...new Set(a.shots.filter(s=>s.animation===animation&&s.direction===direction).map(s=>s.frame))].sort((x,y)=>x-y);if(!frames.length)return toast('No frames');const canvas=$('#previewCanvas'),ctx=canvas.getContext('2d');canvas.width=a.target_width;canvas.height=a.target_height;let index=0;const render=async()=>{ctx.clearRect(0,0,canvas.width,canvas.height);for(const layer of layers){const s=layer.shots.find(x=>x.animation===animation&&x.direction===direction&&x.frame===frames[index]),c=s&&s.candidates.find(x=>x.id===s.approved_candidate_id);if(!c)continue;const img=new Image;img.src='/'+c.image.path;await img.decode();ctx.drawImage(img,0,0,canvas.width,canvas.height)}$('#previewLabel').textContent=`${animation} · dir ${direction} · frame ${frames[index]} · ${layers.length} layer(s)`;index=(index+1)%frames.length};$('#previewDialog').showModal();await render();previewTimer=setInterval(render,Math.max(20,1000/a.fps))}function stopPreview(close=true){if(previewTimer)clearInterval(previewTimer);previewTimer=null;if(close)$('#previewDialog').close()}
|
||||
const mc=$('#maskCanvas');function paint(e){if(!painting)return;e.preventDefault();const r=mc.getBoundingClientRect(),x=(e.clientX-r.left)*mc.width/r.width,y=(e.clientY-r.top)*mc.height/r.height,c=mc.getContext('2d');c.fillStyle=e.buttons===2?'#000':'#fff';c.beginPath();c.arc(x,y,+$('#brush').value*mc.width/r.width,0,Math.PI*2);c.fill()}mc.onpointerdown=e=>{painting=true;mc.setPointerCapture(e.pointerId);paint(e)};mc.onpointermove=paint;mc.onpointerup=()=>painting=false;mc.oncontextmenu=e=>e.preventDefault();async function saveMask(){try{project=await api('/api/controls',{...maskTarget,role:'mask',data_url:mc.toDataURL('image/png')});$('#maskDialog').close();render()}catch(x){toast(x)}}
|
||||
load().catch(toast)
|
||||
</script></body></html>
|
||||
59
src/spriteforge/watch.py
Normal file
59
src/spriteforge/watch.py
Normal file
@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
from typing import Iterable
|
||||
|
||||
from .build import build_assets
|
||||
from .manifest import AssetEntry, load_library
|
||||
|
||||
|
||||
def watched_files(entries: Iterable[AssetEntry]) -> set[Path]:
|
||||
paths = {entry.manifest.resolve() for entry in entries}
|
||||
for entry in entries:
|
||||
for value in (entry.spec.rig, entry.spec.source):
|
||||
if value:
|
||||
paths.add((entry.manifest.parent / value).resolve())
|
||||
return paths
|
||||
|
||||
|
||||
def snapshot(paths: Iterable[Path]) -> dict[Path, tuple[int, int] | None]:
|
||||
result = {}
|
||||
for path in paths:
|
||||
try:
|
||||
stat = path.stat(); result[path] = (stat.st_mtime_ns, stat.st_size)
|
||||
except OSError:
|
||||
result[path] = None
|
||||
return result
|
||||
|
||||
|
||||
def write_reload(output: Path, changed: Iterable[str]) -> None:
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
target = output / "reload.json"
|
||||
payload = {"version": 1, "sequence": time.time_ns(), "assets": sorted(changed)}
|
||||
temporary = target.with_name(f".{target.name}.{os.getpid()}.tmp")
|
||||
temporary.write_text(json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8")
|
||||
temporary.replace(target)
|
||||
|
||||
|
||||
def run(manifests: list[Path], output: Path, cache_dir: Path, jobs: int, interval: float) -> None:
|
||||
entries = load_library(manifests)
|
||||
build_assets(entries, output, cache_dir=cache_dir, jobs=jobs)
|
||||
write_reload(output, (entry.id for entry in entries))
|
||||
state = snapshot(watched_files(entries))
|
||||
while True:
|
||||
time.sleep(interval)
|
||||
fresh_entries = load_library(manifests)
|
||||
fresh = snapshot(watched_files(fresh_entries))
|
||||
if fresh == state:
|
||||
continue
|
||||
old = state; state = fresh
|
||||
changed_paths = {path for path in set(old) | set(fresh) if old.get(path) != fresh.get(path)}
|
||||
selected = [entry for entry in fresh_entries if entry.manifest.resolve() in changed_paths or
|
||||
any(value and (entry.manifest.parent / value).resolve() in changed_paths for value in (entry.spec.rig, entry.spec.source))]
|
||||
if not selected:
|
||||
selected = list(fresh_entries)
|
||||
build_assets(selected, output, cache_dir=cache_dir, jobs=jobs, preserve_existing=True)
|
||||
write_reload(output, (entry.id for entry in selected))
|
||||
1
tests/.gitkeep
Normal file
1
tests/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
20
tests/c99_smoke.c
Normal file
20
tests/c99_smoke.c
Normal file
@ -0,0 +1,20 @@
|
||||
#include "sfa.h"
|
||||
|
||||
int main(int argc,char** argv){
|
||||
sfa_owned file; sfa_frame frame; sfa_animation animation;sfa_direction direction;uint16_t layer;uint8_t palette[768]={0}; uint8_t map[256];
|
||||
uint32_t pixels[16]; sfa_surface surface={pixels,4,4,4}; unsigned i; sfa_light light={0,0,1,32};
|
||||
if(argc!=2||sfa_load_file(argv[1],&file)!=SFA_OK)return 1;
|
||||
if(sfa_get_frame(&file.view,0,&frame)!=SFA_OK)return 2;
|
||||
if(sfa_get_animation(&file.view,0,&animation)!=SFA_OK||strcmp(animation.name,"idle"))return 7;
|
||||
if(sfa_get_direction(&file.view,0,&direction)!=SFA_OK||direction.centidegrees!=0)return 8;
|
||||
if(sfa_get_layer_order(&file.view,0,0,&layer)!=SFA_OK||layer!=0)return 9;
|
||||
for(i=0;i<256;i++)map[i]=(uint8_t)i;
|
||||
palette[3]=255; palette[7]=255; palette[11]=255;
|
||||
for(i=0;i<16;i++)pixels[i]=0xff101010u;
|
||||
if(sfa_blit(&file.view,&frame,palette,0,surface,2,3,SFA_BLEND_OPAQUE)!=SFA_OK)return 3;
|
||||
if(pixels[1]!=0xff0000ffu||pixels[2]!=0xff0000ffu)return 4;
|
||||
map[1]=2;
|
||||
if(sfa_blit(&file.view,&frame,palette,map,surface,2,3,SFA_BLEND_ALPHA50)!=SFA_OK)return 5;
|
||||
if(sfa_blit_lit(&file.view,&frame,palette,0,surface,2,3,SFA_BLEND_OPAQUE,light)!=SFA_OK)return 6;
|
||||
sfa_free_file(&file);return 0;
|
||||
}
|
||||
8
tests/cpp17_smoke.cpp
Normal file
8
tests/cpp17_smoke.cpp
Normal file
@ -0,0 +1,8 @@
|
||||
#include "sfa.hpp"
|
||||
#include <cstdint>
|
||||
int main(int argc,char** argv){
|
||||
if(argc!=2)return 1;
|
||||
spriteforge::File file(argv[1]);
|
||||
auto frame=file.frame(0);
|
||||
return frame.width==4&&frame.height==3&&frame.duration_ms==83?0:2;
|
||||
}
|
||||
9
tests/make_fixture.py
Normal file
9
tests/make_fixture.py
Normal file
@ -0,0 +1,9 @@
|
||||
from pathlib import Path
|
||||
import sys
|
||||
sys.path.insert(0,str(Path(__file__).parents[1]/"src"))
|
||||
from spriteforge.format import Frame,write_sfa
|
||||
|
||||
out=Path(sys.argv[1])
|
||||
f=Frame(bytes([0,1,1,0,2,2,2,0,0,3,0,0]),4,3,2,3,83,
|
||||
bytes([0,0]*12),bytes([0,1,2,0,4,5,6,0,0,9,0,0]),-1.0,1.0)
|
||||
write_sfa(out,[f],animation="idle")
|
||||
5
tests/make_palette_fixture.py
Normal file
5
tests/make_palette_fixture.py
Normal file
@ -0,0 +1,5 @@
|
||||
from pathlib import Path
|
||||
import sys
|
||||
sys.path.insert(0,str(Path(__file__).parents[1]/"src"))
|
||||
from spriteforge.palette import write_sfp
|
||||
write_sfp(Path(sys.argv[1]),{"gray":[(i,i,i) for i in range(256)]},{"identity":list(range(256))})
|
||||
12
tests/sfp_c99_smoke.c
Normal file
12
tests/sfp_c99_smoke.c
Normal file
@ -0,0 +1,12 @@
|
||||
#include "sfa.h"
|
||||
int main(int argc,char** argv){
|
||||
FILE* f;long n;uint8_t* data;size_t got;sfp_view view;const uint8_t* rgb;const uint8_t* map;
|
||||
if(argc!=2||(f=fopen(argv[1],"rb"))==0)return 1;
|
||||
if(fseek(f,0,SEEK_END)||(n=ftell(f))<0||fseek(f,0,SEEK_SET)){fclose(f);return 2;}
|
||||
data=(uint8_t*)malloc((size_t)n);if(!data){fclose(f);return 3;}
|
||||
got=fread(data,1,(size_t)n,f);fclose(f);
|
||||
if(got!=(size_t)n||sfp_open(data,got,&view)!=SFA_OK||view.palette_count!=1||view.colormap_count!=1)return 4;
|
||||
if(sfp_palette(&view,0,&rgb)!=SFA_OK||rgb[3]!=1||rgb[4]!=1||rgb[5]!=1)return 5;
|
||||
if(sfp_colormap(&view,0,&map)!=SFA_OK||map[0]!=0||map[255]!=255)return 6;
|
||||
free(data);return 0;
|
||||
}
|
||||
13
tests/test_backend_registry.py
Normal file
13
tests/test_backend_registry.py
Normal file
@ -0,0 +1,13 @@
|
||||
import pytest
|
||||
from spriteforge.backend_registry import register_backend_contract
|
||||
from spriteforge.manifest import ManifestInvalid, load_manifest
|
||||
|
||||
def test_unknown_backend_is_rejected(tmp_path):
|
||||
path=tmp_path/"bad.yaml";path.write_text("thing:\n backend: typo_backend\n")
|
||||
with pytest.raises(ManifestInvalid,match="unknown backend"): load_manifest(path)
|
||||
|
||||
def test_sixth_backend_registers_without_core_changes(tmp_path):
|
||||
register_backend_contract("test_plugin_backend",lambda spec: None,replace=True)
|
||||
path=tmp_path/"plugin.yaml"
|
||||
path.write_text("thing:\n backend: test_plugin_backend\n params: {quality: 2}\n")
|
||||
assert load_manifest(path).assets[0].spec.backend=="test_plugin_backend"
|
||||
109
tests/test_blender_backend.py
Normal file
109
tests/test_blender_backend.py
Normal file
@ -0,0 +1,109 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
|
||||
from spriteforge.backends.base import BackendContext
|
||||
from spriteforge.backends.blender import BlenderBackend,resolve_executable
|
||||
from spriteforge.backends.layering import analyze_depth_order
|
||||
from spriteforge.manifest import AssetSpec
|
||||
|
||||
def layer(color,depth_value):
|
||||
rgba=np.zeros((4,4,4),dtype=np.uint8);rgba[1:3,1:3]=(*color,255)
|
||||
depth=np.full((4,4),np.inf,dtype=np.float32);depth[1:3,1:3]=depth_value
|
||||
return rgba,depth
|
||||
|
||||
def test_depth_order_is_back_to_front():
|
||||
far,far_z=layer((10,20,30),5);near,near_z=layer((200,100,50),2)
|
||||
analysis=analyze_depth_order(["far","near"],[far,near],[far_z,near_z])
|
||||
assert analysis.order==(0,1) and not analysis.warnings
|
||||
|
||||
def test_crossing_depth_ranges_warn_with_context():
|
||||
a,az=layer((1,1,1),2);b,bz=layer((2,2,2),3)
|
||||
az[1,1]=4;az[1,2]=2;az[2,1]=4;az[2,2]=2
|
||||
analysis=analyze_depth_order(["arm","body"],[a,b],[az,bz],crossing_ratio=.1,context="asset=test direction=0 frame=2")
|
||||
assert analysis.warnings and "split into passes" in analysis.warnings[0] and "frame=2" in analysis.warnings[0]
|
||||
|
||||
def test_blender_protocol_without_real_blender(tmp_path,monkeypatch):
|
||||
rig=tmp_path/"rig.blend";rig.write_bytes(b"fake blend dependency")
|
||||
spec=AssetSpec(backend="blender",rig=rig.name,parts={"body":"Body","head":"Head"},anims=["idle","walk"],dirs=2,fps=10)
|
||||
|
||||
def fake_run(command,**kwargs):
|
||||
request_path=Path(command[command.index("--request")+1]);output=Path(command[command.index("--output")+1]);result_path=Path(command[command.index("--result")+1])
|
||||
request=json.loads(request_path.read_text());output.mkdir()
|
||||
frames=[]
|
||||
for animation in request["animations"]:
|
||||
for direction in range(request["dirs"]):
|
||||
parts=[]
|
||||
for index,name in enumerate(request["parts"]):
|
||||
rgba,depth=layer((50+index*100,40,30),5-index*3)
|
||||
normal=np.zeros((4,4,2),dtype=np.uint8)
|
||||
stem=f"{animation}_{direction}_{index}"
|
||||
np.save(output/f"{stem}_r.npy",rgba);np.save(output/f"{stem}_d.npy",depth);np.save(output/f"{stem}_n.npy",normal)
|
||||
parts.append({"name":name,"rgba":f"{stem}_r.npy","depth":f"{stem}_d.npy","normal":f"{stem}_n.npy"})
|
||||
frames.append({"animation":animation,"direction_index":direction,"frame_index":0,"duration_ms":100,"parts":parts})
|
||||
result={"version":1,"directions_centidegrees":[0,18000],
|
||||
"animations":[{"name":"idle","frames_per_direction":1},{"name":"walk","frames_per_direction":1}],"frames":frames}
|
||||
result_path.write_text(json.dumps(result));return SimpleNamespace(returncode=0,stdout="",stderr="")
|
||||
|
||||
monkeypatch.setattr("spriteforge.backends.blender.subprocess.run",fake_run)
|
||||
backend=BlenderBackend();raw=backend.generate(spec,BackendContext(tmp_path,"actor"))
|
||||
assert len(raw.frames)==4 and raw.animations==(("idle",1),("walk",1))
|
||||
assert raw.layer_count==2 and all(order==(0,1) for order in raw.layer_orders)
|
||||
assert raw.frames[0].depth is not None and raw.frames[0].normals_xy is not None
|
||||
assert backend.dependencies(spec,BackendContext(tmp_path,"actor"))==(rig.resolve(),)
|
||||
|
||||
def test_blender_forwards_clip_sampling_and_layer_groups(tmp_path,monkeypatch):
|
||||
rig=tmp_path/"rig.blend";rig.write_bytes(b"fake")
|
||||
captured={}
|
||||
spec=AssetSpec(backend="blender",rig=rig.name,parts={"body":"Body"},anims=["idle","walk"],dirs=8,fps=12,
|
||||
params={"actions":{"idle":"Idle_Loop","walk":"Walk_Loop"},
|
||||
"frames":{"idle":4,"walk":8},"part_members":{"body":["Body","Helmet"]},
|
||||
"animation_fps":{"idle":8,"walk":12},
|
||||
"part_prefixes":{"body":"actor_"},
|
||||
"direction_offset_deg":90,"camera_target":[0,0,1],"model_rotation_deg":-90,
|
||||
"loop_sampling":{"idle":True,"walk":False}})
|
||||
|
||||
def fake_run(command,**kwargs):
|
||||
request_path=Path(command[command.index("--request")+1]);captured.update(json.loads(request_path.read_text()))
|
||||
return SimpleNamespace(returncode=1,stdout="expected stop",stderr="")
|
||||
|
||||
monkeypatch.setattr("spriteforge.backends.blender.subprocess.run",fake_run)
|
||||
try:BlenderBackend().generate(spec,BackendContext(tmp_path,"actor"))
|
||||
except ValueError:pass
|
||||
assert captured["actions"]=={"idle":"Idle_Loop","walk":"Walk_Loop"}
|
||||
assert captured["frames"]=={"idle":4,"walk":8}
|
||||
assert captured["part_members"]=={"body":["Body","Helmet"]}
|
||||
assert captured["part_prefixes"]=={"body":"actor_"}
|
||||
assert captured["animation_fps"]=={"idle":8,"walk":12}
|
||||
assert captured["direction_offset_deg"]==90 and captured["camera_target"]==[0,0,1]
|
||||
assert captured["loop_sampling"]=={"idle":True,"walk":False}
|
||||
|
||||
def test_blender_rejects_sampling_for_undeclared_clip(tmp_path):
|
||||
rig=tmp_path/"rig.blend";rig.write_bytes(b"fake")
|
||||
spec=AssetSpec(backend="blender",rig=rig.name,parts={"body":"Body"},anims=["idle"],
|
||||
params={"frames":{"walk":8}})
|
||||
try:BlenderBackend().generate(spec,BackendContext(tmp_path,"actor"))
|
||||
except ValueError as error:assert "declared anims" in str(error)
|
||||
else:raise AssertionError("undeclared sampling clip was accepted")
|
||||
|
||||
def test_blender_executable_uses_path(monkeypatch):
|
||||
monkeypatch.setattr("spriteforge.backends.blender.shutil.which",lambda value:"/tools/blender" if value=="blender" else None)
|
||||
assert resolve_executable("blender")=="/tools/blender"
|
||||
|
||||
def test_blender_executable_reports_configuration(monkeypatch):
|
||||
monkeypatch.setattr("spriteforge.backends.blender.shutil.which",lambda value:None)
|
||||
try:resolve_executable("missing-blender")
|
||||
except ValueError as error:assert "BLENDER_BIN" in str(error)
|
||||
else:raise AssertionError("missing Blender executable was accepted")
|
||||
|
||||
def test_missing_driver_result_includes_blender_log(tmp_path,monkeypatch):
|
||||
rig=tmp_path/"rig.blend";rig.write_bytes(b"fake")
|
||||
spec=AssetSpec(backend="blender",rig=rig.name,parts={"body":"Body"},anims=["idle"])
|
||||
monkeypatch.setattr("spriteforge.backends.blender.resolve_executable",lambda value:"blender")
|
||||
monkeypatch.setattr("spriteforge.backends.blender.subprocess.run",lambda *args,**kwargs:
|
||||
SimpleNamespace(returncode=0,stdout="driver traceback marker",stderr=""))
|
||||
try:BlenderBackend().generate(spec,BackendContext(tmp_path,"actor"))
|
||||
except ValueError as error:assert "driver traceback marker" in str(error)
|
||||
else:raise AssertionError("missing driver result was accepted")
|
||||
47
tests/test_build_stage3.py
Normal file
47
tests/test_build_stage3.py
Normal file
@ -0,0 +1,47 @@
|
||||
import json
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from spriteforge.cli import app
|
||||
from spriteforge.reader import decode_sfa
|
||||
from spriteforge.palette import decode_sfp
|
||||
|
||||
runner=CliRunner()
|
||||
|
||||
def test_end_to_end_procedural_build(tmp_path):
|
||||
manifest=tmp_path/"assets.yaml"
|
||||
manifest.write_text("""\
|
||||
defaults: {dirs: 1, fps: 10, seed: 77}
|
||||
floor:
|
||||
backend: procedural
|
||||
generator: floor_tile
|
||||
params: {width: 32, height: 16, variants: 2, color: '#786858'}
|
||||
wall:
|
||||
backend: procedural
|
||||
generator: wall_tile
|
||||
params: {width: 32, top_height: 16, height: 24}
|
||||
""",encoding="utf-8")
|
||||
output=tmp_path/"output"
|
||||
result=runner.invoke(app,["build",str(manifest),"--output",str(output)])
|
||||
assert result.exit_code==0,result.output
|
||||
assert "built 2 asset(s)" in result.stdout
|
||||
floor=decode_sfa((output/"floor.sfa").read_bytes())
|
||||
palettes=decode_sfp((output/"palettes.sfp").read_bytes())
|
||||
index=json.loads((output/"index.json").read_text())
|
||||
assert len(floor.frames)==2 and floor.frames[0].duration_ms==100
|
||||
assert floor.frames[0].normals_xy is not None and floor.frames[0].depth is not None
|
||||
assert "default" in palettes.palettes
|
||||
assert [item["id"] for item in index["assets"]]==["floor","wall"]
|
||||
second=tmp_path/"output2"
|
||||
repeated=runner.invoke(app,["build",str(manifest),"--output",str(second)])
|
||||
assert repeated.exit_code==0
|
||||
for name in ("floor.sfa","wall.sfa","palettes.sfp","index.json"):
|
||||
assert (output/name).read_bytes()==(second/name).read_bytes()
|
||||
|
||||
def test_import_backend_reports_missing_source_without_writing(tmp_path):
|
||||
manifest=tmp_path/"assets.yaml"
|
||||
manifest.write_text("icon:\n backend: import\n source: icon.png\n")
|
||||
output=tmp_path/"output"
|
||||
result=runner.invoke(app,["build",str(manifest),"--output",str(output)])
|
||||
assert result.exit_code==1 and "dependency does not exist" in result.stderr
|
||||
assert not output.exists()
|
||||
assert not output.exists()
|
||||
21
tests/test_cli.py
Normal file
21
tests/test_cli.py
Normal file
@ -0,0 +1,21 @@
|
||||
from typer.testing import CliRunner
|
||||
from spriteforge.cli import app
|
||||
|
||||
runner=CliRunner()
|
||||
|
||||
def test_validate_and_dry_run(tmp_path):
|
||||
path=tmp_path/"assets.yaml"
|
||||
path.write_text("floor:\n backend: procedural\n generator: floor\n tags: [environment]\n")
|
||||
result=runner.invoke(app,["validate",str(path)])
|
||||
assert result.exit_code==0 and "1 asset(s)" in result.stdout
|
||||
result=runner.invoke(app,["build",str(path),"--tag","environment","--jobs","3","--dry-run"])
|
||||
assert result.exit_code==0 and "floor\tprocedural" in result.stdout and "jobs=3" in result.stdout
|
||||
|
||||
def test_cli_reports_line(tmp_path):
|
||||
path=tmp_path/"bad.yaml";path.write_text("Bad-ID:\n backend: import\n source: x.png\n")
|
||||
result=runner.invoke(app,["validate",str(path)])
|
||||
assert result.exit_code==1 and ":1:1:" in result.stderr
|
||||
|
||||
def test_missing_catalog_is_explicit():
|
||||
result=runner.invoke(app,["stats","--build-dir","missing"])
|
||||
assert result.exit_code==1 and "index.json" in result.stderr
|
||||
58
tests/test_comfyui_provider.py
Normal file
58
tests/test_comfyui_provider.py
Normal file
@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import io, json
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from spriteforge.studio.models import GenerationRecipe, GenerationRequest
|
||||
from spriteforge.studio.providers import ComfyUIProvider
|
||||
|
||||
|
||||
class FakeComfy(BaseHTTPRequestHandler):
|
||||
prompt = None
|
||||
def log_message(self,*args): pass
|
||||
def do_GET(self):
|
||||
if self.path=="/system_stats": return self.reply({"system":{"os":"fake"}})
|
||||
if self.path=="/history/job1": return self.reply({"job1":{"outputs":{"9":{"images":[{"filename":"out.png","subfolder":"","type":"output"}]}}}})
|
||||
if self.path.startswith("/view?"):
|
||||
image=Image.new("RGBA",(12,10),(1,2,3,255)); data=io.BytesIO();image.save(data,"PNG")
|
||||
blob=data.getvalue();self.send_response(200);self.send_header("Content-Length",str(len(blob)));self.end_headers();self.wfile.write(blob);return
|
||||
self.send_error(404)
|
||||
def do_POST(self):
|
||||
length=int(self.headers["Content-Length"]);body=self.rfile.read(length)
|
||||
if self.path=="/upload/image": return self.reply({"name":"uploaded.png"})
|
||||
if self.path=="/prompt":
|
||||
FakeComfy.prompt=json.loads(body)["prompt"];return self.reply({"prompt_id":"job1"})
|
||||
self.send_error(404)
|
||||
def reply(self,value):
|
||||
body=json.dumps(value).encode();self.send_response(200);self.send_header("Content-Type","application/json");self.send_header("Content-Length",str(len(body)));self.end_headers();self.wfile.write(body)
|
||||
|
||||
|
||||
def test_comfyui_workflow_substitution_upload_and_download(tmp_path:Path):
|
||||
server=ThreadingHTTPServer(("127.0.0.1",0),FakeComfy);thread=Thread(target=server.serve_forever,daemon=True);thread.start()
|
||||
workflow=tmp_path/"workflow.json";workflow.write_text(json.dumps({"1":{"class_type":"TestNode","inputs":{"text":"{{PROMPT}}","negative":"{{NEGATIVE_PROMPT}}","seed":"{{SEED}}","image":"{{REFERENCE_0}}","width":"{{WIDTH}}","height":"{{HEIGHT}}"}}}))
|
||||
provider=ComfyUIProvider(f"http://127.0.0.1:{server.server_port}",workflow,poll_interval=.001,timeout=2)
|
||||
request=GenerationRequest(project_id="p",asset_id="a",shot_id="s",count=1,recipe=GenerationRecipe(provider="comfyui",model="sd15",seed=42,prompt="horror",width=256,height=256,reference_hashes=["a"*64],controls={"mask":"b"*64}))
|
||||
try:
|
||||
assert provider.check()["system"]["os"]=="fake"
|
||||
output=provider.generate(request,lambda _: b"fake png")
|
||||
assert output[0].startswith(b"\x89PNG")
|
||||
inputs=FakeComfy.prompt["1"]["inputs"]
|
||||
assert inputs=={"text":"horror","negative":"","seed":42,"image":"uploaded.png","width":256,"height":256}
|
||||
finally:server.shutdown();server.server_close()
|
||||
|
||||
|
||||
def test_workflow_preflight_rejects_ui_format_and_missing_markers(tmp_path:Path):
|
||||
from spriteforge.studio.providers import workflow_issues
|
||||
ui=tmp_path/"ui.json";ui.write_text(json.dumps({"nodes":[],"links":[]}));assert "UI format" in workflow_issues(ui)[0]
|
||||
incomplete=tmp_path/"incomplete.json";incomplete.write_text(json.dumps({"1":{"class_type":"X","inputs":{"text":"{{PROMPT}}"}}}))
|
||||
assert "missing required" in workflow_issues(incomplete)[0]
|
||||
|
||||
|
||||
def test_bundled_baseline_workflow_passes_preflight():
|
||||
from spriteforge.studio.providers import workflow_issues
|
||||
path=Path(__file__).parents[1]/"examples"/"comfyui"/"txt2img_api.json"
|
||||
assert workflow_issues(path)==[]
|
||||
44
tests/test_format.py
Normal file
44
tests/test_format.py
Normal file
@ -0,0 +1,44 @@
|
||||
import struct
|
||||
import pytest
|
||||
from spriteforge.format import Animation,FLAG_DEPTH, FLAG_NORMALS, Frame, encode_sfa, fnv1a32
|
||||
from spriteforge.reader import decode_sfa
|
||||
from spriteforge.palette import decode_sfp, encode_sfp
|
||||
|
||||
def sample():
|
||||
return Frame(bytes([0,1,1,0,2,2,2,0,0,3,0,0]),4,3,2,3,83,bytes([0,0]*12),bytes([0,1,2,0,4,5,6,0,0,9,0,0]),-1.25,2.5)
|
||||
|
||||
def test_roundtrip_parallel_streams():
|
||||
got=decode_sfa(encode_sfa([sample()],animation="idle",directions=(4500,)))
|
||||
assert got.flags==FLAG_NORMALS|FLAG_DEPTH
|
||||
assert got.animation=="idle" and got.directions==(4500,)
|
||||
assert got.frames[0]==sample() and got.layer_orders==((0,),)
|
||||
|
||||
def test_empty_rows():
|
||||
f=Frame(bytes([0]*8+[4,0,0,4]),4,3)
|
||||
assert decode_sfa(encode_sfa([f])).frames[0]==f
|
||||
|
||||
@pytest.mark.parametrize("kind",["magic","size","stream"])
|
||||
def test_corrupt_rejected(kind):
|
||||
blob=bytearray(encode_sfa([sample()]))
|
||||
if kind=="magic": blob[0]=0
|
||||
elif kind=="size": struct.pack_into("<I",blob,12,len(blob)+1)
|
||||
else:
|
||||
fo=struct.unpack_from("<I",blob,40)[0]; struct.pack_into("<I",blob,fo+20,len(blob)+10)
|
||||
with pytest.raises(ValueError): decode_sfa(bytes(blob))
|
||||
|
||||
def test_validation_hash_palette():
|
||||
assert fnv1a32("idle")==0xC301CF93
|
||||
with pytest.raises(ValueError): encode_sfa([Frame(b"\1",2,2)])
|
||||
p=[(i,i,i) for i in range(256)]
|
||||
blob=encode_sfp({"gray":p},{"identity":list(range(256))})
|
||||
assert blob[:4]==b"SFP\0" and decode_sfp(blob).colormaps["identity"]==bytes(range(256))
|
||||
with pytest.raises(ValueError): encode_sfp({"bad":p[:-1]})
|
||||
|
||||
def test_multiple_animations_and_layer_orders():
|
||||
frames=[sample() for _ in range(6)]
|
||||
blob=encode_sfa(frames,directions=(0,18000),layer_count=3,
|
||||
animations=(Animation("idle",1),Animation("walk",2)),
|
||||
layer_orders=((0,1,2),(2,1,0),(0,2,1),(1,2,0),(2,0,1),(1,0,2)))
|
||||
decoded=decode_sfa(blob)
|
||||
assert decoded.animations==(("idle",0,1),("walk",2,2))
|
||||
assert decoded.layer_orders[1]==(2,1,0) and len(decoded.frames)==6
|
||||
55
tests/test_import_composite.py
Normal file
55
tests/test_import_composite.py
Normal file
@ -0,0 +1,55 @@
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from spriteforge.backends import BackendContext, create_backend, implemented_backends
|
||||
from spriteforge.build import build_assets
|
||||
from spriteforge.manifest import AssetSpec, load_library
|
||||
from spriteforge.reader import decode_sfa
|
||||
|
||||
|
||||
def _png(path: Path, rgba: np.ndarray) -> None:
|
||||
Image.fromarray(rgba, "RGBA").save(path)
|
||||
|
||||
|
||||
def test_import_splits_sheet_and_sets_directions(tmp_path: Path):
|
||||
sheet = np.zeros((4, 8, 4), dtype=np.uint8)
|
||||
sheet[:, :4] = (255, 0, 0, 255); sheet[:, 4:] = (0, 255, 0, 255)
|
||||
_png(tmp_path / "sheet.png", sheet)
|
||||
spec = AssetSpec(backend="import", source="sheet.png", dirs=2, fps=10,
|
||||
params={"frame_width": 4, "frame_height": 4})
|
||||
backend = create_backend("import")
|
||||
assert backend.dependencies(spec, BackendContext(tmp_path, "hero")) == (tmp_path / "sheet.png",)
|
||||
asset = backend.generate(spec, BackendContext(tmp_path, "hero"))
|
||||
assert len(asset.frames) == 2 and asset.directions_centidegrees == (0, 18000)
|
||||
assert asset.frames[0].duration_ms == 100
|
||||
|
||||
|
||||
def test_composite_alpha_blends_in_manifest_order(tmp_path: Path):
|
||||
bottom = np.zeros((2, 2, 4), dtype=np.uint8); bottom[:] = (200, 0, 0, 255)
|
||||
top = np.zeros((2, 2, 4), dtype=np.uint8); top[:] = (0, 0, 200, 128)
|
||||
_png(tmp_path / "bottom.png", bottom); _png(tmp_path / "top.png", top)
|
||||
spec = AssetSpec(backend="composite", layers=["bottom.png", "top.png"], dirs=1)
|
||||
asset = create_backend("composite").generate(spec, BackendContext(tmp_path, "layered"))
|
||||
pixel = asset.frames[0].rgba[0, 0]
|
||||
assert tuple(pixel) == (100, 0, 100, 255)
|
||||
assert asset.layer_count == 2
|
||||
|
||||
|
||||
def test_all_local_backends_are_registered():
|
||||
assert {"procedural", "blender", "import", "composite"} <= set(implemented_backends())
|
||||
assert "image_api" not in implemented_backends()
|
||||
|
||||
|
||||
def test_import_and_composite_build_real_sfa_files(tmp_path: Path):
|
||||
red = np.zeros((4, 4, 4), dtype=np.uint8); red[:] = (200, 20, 20, 255)
|
||||
blue = np.zeros((4, 4, 4), dtype=np.uint8); blue[1:3, 1:3] = (20, 20, 200, 255)
|
||||
_png(tmp_path / "red.png", red); _png(tmp_path / "blue.png", blue)
|
||||
manifest = tmp_path / "assets.yaml"
|
||||
manifest.write_text("imported:\n backend: import\n source: red.png\nlayered:\n backend: composite\n layers: [red.png, blue.png]\n")
|
||||
result = build_assets(load_library([manifest]), tmp_path / "build", cache_dir=tmp_path / "cache")
|
||||
assert result.asset_count == 2
|
||||
assert decode_sfa((tmp_path / "build" / "imported.sfa").read_bytes()).frames
|
||||
layered = decode_sfa((tmp_path / "build" / "layered.sfa").read_bytes())
|
||||
assert layered.layer_orders == ((0, 1),)
|
||||
84
tests/test_incremental.py
Normal file
84
tests/test_incremental.py
Normal file
@ -0,0 +1,84 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from spriteforge.backend_registry import register_backend_contract
|
||||
from spriteforge.backends.base import Backend,BackendContext,RawAsset
|
||||
from spriteforge.backends.registry import register_backend
|
||||
from spriteforge.cache import input_hash
|
||||
from spriteforge.cli import app
|
||||
from spriteforge.manifest import AssetEntry,AssetSpec
|
||||
|
||||
runner=CliRunner()
|
||||
|
||||
def manifest_file(tmp_path):
|
||||
path=tmp_path/"assets.yaml"
|
||||
path.write_text("""\
|
||||
a_floor:
|
||||
backend: procedural
|
||||
generator: floor_tile
|
||||
params: {width: 24, height: 12, color: '#605040'}
|
||||
b_decal:
|
||||
backend: procedural
|
||||
generator: decal
|
||||
params: {width: 20, height: 10, color: '#904030'}
|
||||
""")
|
||||
return path
|
||||
|
||||
def build(path,output,cache,*extra):
|
||||
return runner.invoke(app,["build",str(path),"--output",str(output),"--cache-dir",str(cache),*extra])
|
||||
|
||||
def test_second_build_hits_artifact_cache(tmp_path):
|
||||
path=manifest_file(tmp_path);output=tmp_path/"out";cache=tmp_path/"cache"
|
||||
first=build(path,output,cache,"--jobs","2")
|
||||
assert first.exit_code==0 and "generated=2" in first.stdout
|
||||
second=build(path,output,cache,"--jobs","2")
|
||||
assert second.exit_code==0 and "artifact=2" in second.stdout and "generated=0" in second.stdout
|
||||
|
||||
def test_raw_cache_survives_artifact_eviction(tmp_path):
|
||||
path=manifest_file(tmp_path);output=tmp_path/"out";cache=tmp_path/"cache"
|
||||
assert build(path,output,cache).exit_code==0
|
||||
shutil.rmtree(cache/"artifacts")
|
||||
repeated=build(path,output,cache)
|
||||
assert repeated.exit_code==0 and "raw=2" in repeated.stdout and "artifact=0" in repeated.stdout
|
||||
|
||||
def test_metadata_tags_do_not_invalidate_render_or_artifact(tmp_path):
|
||||
path=manifest_file(tmp_path);output=tmp_path/"out";cache=tmp_path/"cache"
|
||||
assert build(path,output,cache).exit_code==0
|
||||
path.write_text(path.read_text().replace("a_floor:\n", "a_floor:\n tags: [environment]\n"))
|
||||
repeated=build(path,output,cache)
|
||||
assert repeated.exit_code==0 and "generated=0" in repeated.stdout and "artifact=2" in repeated.stdout
|
||||
index=json.loads((output/"index.json").read_text())
|
||||
assert next(item for item in index["assets"] if item["id"]=="a_floor")["tags"]==["environment"]
|
||||
|
||||
def test_one_asset_change_preserves_other_and_palette(tmp_path):
|
||||
path=manifest_file(tmp_path);output=tmp_path/"out";cache=tmp_path/"cache"
|
||||
assert build(path,output,cache).exit_code==0
|
||||
old=json.loads((output/"index.json").read_text());old_items={x["id"]:x for x in old["assets"]};palette=old["palette"]["hash"]
|
||||
path.write_text(path.read_text().replace("#605040","#405870"))
|
||||
changed=build(path,output,cache,"--asset","a_floor")
|
||||
assert changed.exit_code==0 and "generated=1" in changed.stdout
|
||||
new=json.loads((output/"index.json").read_text());new_items={x["id"]:x for x in new["assets"]}
|
||||
assert new_items["b_decal"]==old_items["b_decal"]
|
||||
assert new_items["a_floor"]["hash"]!=old_items["a_floor"]["hash"]
|
||||
assert new["palette"]["hash"]==palette
|
||||
|
||||
class DependencyBackend(Backend):
|
||||
name="dependency_test";version="1"
|
||||
def dependencies(self,spec,context):return (Path(spec.params["path"]),)
|
||||
def generate(self,spec,context)->RawAsset:raise AssertionError("not used")
|
||||
|
||||
def test_dependency_bytes_invalidate_input_hash(tmp_path):
|
||||
register_backend_contract("dependency_test",lambda spec:None,replace=True)
|
||||
register_backend("dependency_test",DependencyBackend,replace=True)
|
||||
source=tmp_path/"source.bin";source.write_bytes(b"one")
|
||||
spec=AssetSpec(backend="dependency_test",params={"path":str(source)})
|
||||
entry=AssetEntry("thing",spec,tmp_path/"assets.yaml",1)
|
||||
before=input_hash(entry)[0];source.write_bytes(b"two");after=input_hash(entry)[0]
|
||||
assert before!=after
|
||||
|
||||
def test_filtered_palette_rebuild_is_rejected(tmp_path):
|
||||
path=manifest_file(tmp_path)
|
||||
result=build(path,tmp_path/"out",tmp_path/"cache","--asset","a_floor","--rebuild-palette")
|
||||
assert result.exit_code==2 and "unfiltered full build" in result.stderr
|
||||
78
tests/test_introspection.py
Normal file
78
tests/test_introspection.py
Normal file
@ -0,0 +1,78 @@
|
||||
import json
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from spriteforge.cli import app
|
||||
|
||||
runner=CliRunner()
|
||||
|
||||
def make_library(tmp_path):
|
||||
manifest=tmp_path/"assets.yaml"
|
||||
manifest.write_text("""\
|
||||
floor:
|
||||
backend: procedural
|
||||
generator: floor_tile
|
||||
tags: [environment]
|
||||
params: {width: 24, height: 12, variants: 2, color: '#705840'}
|
||||
decal:
|
||||
backend: procedural
|
||||
generator: decal
|
||||
tags: [effect]
|
||||
params: {width: 20, height: 10, color: '#A03020'}
|
||||
""")
|
||||
output=tmp_path/"library"
|
||||
result=runner.invoke(app,["build",str(manifest),"--output",str(output)])
|
||||
assert result.exit_code==0,result.output
|
||||
return manifest,output
|
||||
|
||||
def test_text_introspection_commands(tmp_path):
|
||||
manifest,output=make_library(tmp_path);common=["--build-dir",str(output)]
|
||||
listed=runner.invoke(app,["list","--tag","environment",*common])
|
||||
assert listed.exit_code==0 and "floor" in listed.stdout and "decal" not in listed.stdout
|
||||
described=runner.invoke(app,["describe","floor",*common])
|
||||
assert described.exit_code==0 and "backend: procedural" in described.stdout and "spec:" in described.stdout
|
||||
preview=runner.invoke(app,["ascii","floor","--frame","1","--columns","12",*common])
|
||||
assert preview.exit_code==0 and "size=" in preview.stdout and ("#" in preview.stdout or "+" in preview.stdout)
|
||||
palette=runner.invoke(app,["palette","default",*common])
|
||||
assert palette.exit_code==0 and "#" in palette.stdout and "transparent" in palette.stdout
|
||||
stats=runner.invoke(app,["stats",*common])
|
||||
assert stats.exit_code==0 and "assets: 2" in stats.stdout and "frames: 3" in stats.stdout
|
||||
validated=runner.invoke(app,["validate",str(manifest),*common])
|
||||
assert validated.exit_code==0 and "0 error(s)" in validated.stdout
|
||||
|
||||
def test_diff_uses_text_snapshots(tmp_path):
|
||||
manifest,output=make_library(tmp_path)
|
||||
old=next(item["hash"] for item in json.loads((output/"index.json").read_text())["assets"] if item["id"]=="floor")
|
||||
text=manifest.read_text().replace("#705840","#406080");manifest.write_text(text)
|
||||
assert runner.invoke(app,["build",str(manifest),"--output",str(output)]).exit_code==0
|
||||
result=runner.invoke(app,["diff","floor","--against",old[:12],"--build-dir",str(output)])
|
||||
assert result.exit_code==0 and "changed" in result.stdout and "hash:" in result.stdout
|
||||
|
||||
def test_validate_reports_manifest_asset_without_file(tmp_path):
|
||||
manifest,output=make_library(tmp_path)
|
||||
with manifest.open("a") as stream:
|
||||
stream.write("missing:\n backend: procedural\n generator: decal\n")
|
||||
result=runner.invoke(app,["validate",str(manifest),"--build-dir",str(output)])
|
||||
assert result.exit_code==1 and "missing from catalog" in result.stdout
|
||||
|
||||
def test_contact_sheet_is_human_only_png(tmp_path):
|
||||
_,output=make_library(tmp_path);sheet=tmp_path/"sheet.png"
|
||||
result=runner.invoke(app,["contact-sheet","--build-dir",str(output),"--output",str(sheet)])
|
||||
assert result.exit_code==0 and "human-only" in result.stdout
|
||||
assert sheet.read_bytes()[:8]==b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
def test_backend_layer_warning_is_reported_textually(tmp_path):
|
||||
manifest,output=make_library(tmp_path)
|
||||
index=json.loads((output/"index.json").read_text())
|
||||
index["assets"][0]["warnings"]=["animation=walk direction=2 frame=3: depth ranges overlap; split into passes"]
|
||||
(output/"index.json").write_text(json.dumps(index))
|
||||
result=runner.invoke(app,["validate","--build-dir",str(output)])
|
||||
assert result.exit_code==0 and "WARNING" in result.stdout and "frame=3" in result.stdout
|
||||
|
||||
def test_paperdoll_layer_allows_shared_external_pivot(tmp_path):
|
||||
manifest,output=make_library(tmp_path)
|
||||
index=json.loads((output/"index.json").read_text())
|
||||
floor=next(item for item in index["assets"] if item["id"]=="floor")
|
||||
floor["tags"].append("paperdoll_layer")
|
||||
(output/"index.json").write_text(json.dumps(index))
|
||||
result=runner.invoke(app,["validate",str(manifest),"--build-dir",str(output)])
|
||||
assert result.exit_code==0 and "pivot is far outside" not in result.stdout
|
||||
61
tests/test_manifest.py
Normal file
61
tests/test_manifest.py
Normal file
@ -0,0 +1,61 @@
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from spriteforge.manifest import ManifestInvalid, load_library, load_manifest
|
||||
|
||||
VALID="""\
|
||||
defaults:
|
||||
dirs: 8
|
||||
fps: 12
|
||||
pivot: bottom_center
|
||||
tags: [monster]
|
||||
|
||||
skeleton_warrior:
|
||||
backend: blender
|
||||
rig: rigs/humanoid.blend
|
||||
parts: {torso: ribcage, head: skull}
|
||||
anims: [idle, walk]
|
||||
palettes: [bone_pale, bone_charred]
|
||||
|
||||
crypt_wall:
|
||||
backend: procedural
|
||||
generator: tileset_wall
|
||||
params: {material: stone_cracked, height: 96, variants: 6}
|
||||
dirs: 1
|
||||
|
||||
health_potion:
|
||||
backend: import
|
||||
source: items/health_potion.png
|
||||
size: 32x32
|
||||
palette: items_common
|
||||
"""
|
||||
|
||||
def write(tmp_path: Path,text: str,name="assets.yaml")->Path:
|
||||
path=tmp_path/name;path.write_text(text,encoding="utf-8");return path
|
||||
|
||||
def test_flat_manifest_and_defaults(tmp_path):
|
||||
manifest=load_manifest(write(tmp_path,VALID))
|
||||
assert [x.id for x in manifest.assets]==["skeleton_warrior","crypt_wall","health_potion"]
|
||||
assert manifest.assets[0].spec.dirs==8 and manifest.assets[1].spec.dirs==1
|
||||
assert manifest.assets[2].spec.size=="32x32"
|
||||
|
||||
def test_nested_assets_supported(tmp_path):
|
||||
path=write(tmp_path,"defaults: {fps: 10}\nassets:\n floor_a:\n backend: procedural\n generator: floor\n")
|
||||
assert load_manifest(path).assets[0].spec.fps==10
|
||||
|
||||
def test_error_has_exact_file_and_line(tmp_path):
|
||||
path=write(tmp_path,"bad_asset:\n backend: procedural\n generatr: floor\n")
|
||||
with pytest.raises(ManifestInvalid) as caught: load_manifest(path)
|
||||
rendered="\n".join(map(str,caught.value.errors))
|
||||
assert f"{path.resolve()}:3:3" in rendered and "generatr" in rendered
|
||||
|
||||
def test_backend_contract_and_size(tmp_path):
|
||||
path=write(tmp_path,"potion:\n backend: import\n source: potion.png\n size: 32-32\n")
|
||||
with pytest.raises(ManifestInvalid) as caught: load_manifest(path)
|
||||
assert "WIDTHxHEIGHT" in str(caught.value)
|
||||
missing=write(tmp_path,"potion:\n backend: import\n","missing.yaml")
|
||||
with pytest.raises(ManifestInvalid,match="requires 'source'"): load_manifest(missing)
|
||||
|
||||
def test_duplicate_ids_across_manifests(tmp_path):
|
||||
a=write(tmp_path,"floor:\n backend: procedural\n generator: floor\n","a.yaml")
|
||||
b=write(tmp_path,"floor:\n backend: import\n source: floor.png\n","b.yaml")
|
||||
with pytest.raises(ManifestInvalid,match="duplicate asset id"): load_library([a,b])
|
||||
7
tests/test_manifest_version.py
Normal file
7
tests/test_manifest_version.py
Normal file
@ -0,0 +1,7 @@
|
||||
import pytest
|
||||
from spriteforge.manifest import ManifestInvalid, load_manifest
|
||||
|
||||
def test_future_manifest_version_is_rejected(tmp_path):
|
||||
path=tmp_path/"future.yaml"
|
||||
path.write_text("version: 2\nfloor:\n backend: procedural\n generator: floor\n")
|
||||
with pytest.raises(ManifestInvalid,match="only manifest version 1"): load_manifest(path)
|
||||
142
tests/test_pixel.py
Normal file
142
tests/test_pixel.py
Normal file
@ -0,0 +1,142 @@
|
||||
"""Примитивы пиксель-арта: границы, детерминизм, порядок по глубине."""
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from spriteforge import pixel
|
||||
|
||||
|
||||
def test_colors_are_parsed_and_validated():
|
||||
assert list(pixel.to_rgba("#102030")) == [16, 32, 48, 255]
|
||||
assert list(pixel.to_rgba("#10203040")) == [16, 32, 48, 64]
|
||||
assert list(pixel.to_rgba((1, 2, 3))) == [1, 2, 3, 255]
|
||||
for bad in ("102030", "#1020", "#GGGGGG"):
|
||||
with pytest.raises(ValueError):
|
||||
pixel.to_rgba(bad)
|
||||
|
||||
|
||||
def test_canvas_starts_transparent_and_fills():
|
||||
image = pixel.canvas(5, 3)
|
||||
assert image.shape == (3, 5, 4) and image.dtype == np.uint8 and not image.any()
|
||||
pixel.fill(image, "#204060")
|
||||
assert (image[..., 3] == 255).all()
|
||||
with pytest.raises(ValueError):
|
||||
pixel.canvas(0, 4)
|
||||
|
||||
|
||||
def test_fill_blends_over_the_whole_canvas():
|
||||
image = pixel.canvas(4, 4, "#000000")
|
||||
pixel.fill(image, "#FFFFFF80", blend=True)
|
||||
assert image[0, 0, 0] == 128 and image[0, 0, 3] == 255
|
||||
|
||||
|
||||
def test_set_pixel_ignores_everything_outside_the_canvas():
|
||||
image = pixel.canvas(4, 4)
|
||||
for x, y in ((-1, 0), (0, -1), (4, 0), (0, 4)):
|
||||
pixel.set_pixel(image, x, y, "#FFFFFF")
|
||||
assert not image.any()
|
||||
pixel.set_pixel(image, 3, 3, "#FFFFFF")
|
||||
assert list(image[3, 3]) == [255, 255, 255, 255]
|
||||
|
||||
|
||||
def test_lines_stay_inside_the_canvas_and_thicken():
|
||||
image = pixel.canvas(9, 9)
|
||||
pixel.draw_line(image, -5, 4, 20, 4, "#FFFFFF")
|
||||
assert (image[4, :, 3] == 255).all() and image[3, :, 3].sum() == 0
|
||||
thick = pixel.canvas(9, 9)
|
||||
pixel.draw_line(thick, 4, 1, 4, 7, "#FFFFFF", thickness=3)
|
||||
assert np.count_nonzero(thick[..., 3]) > np.count_nonzero(image[..., 3])
|
||||
assert pixel.line_points(0, 0, 2, 2) == ((0, 0), (1, 1), (2, 2))
|
||||
|
||||
|
||||
def test_rectangles_and_ellipses_support_outlines():
|
||||
image = pixel.canvas(10, 10)
|
||||
pixel.draw_rect(image, 2, 2, 7, 7, "#FFFFFF", filled=False)
|
||||
assert image[2, 2, 3] == 255 and image[4, 4, 3] == 0
|
||||
pixel.draw_rect(image, -3, -3, 1, 1, "#FF0000")
|
||||
assert image[0, 0, 3] == 255
|
||||
ellipse = pixel.canvas(11, 11)
|
||||
pixel.draw_ellipse(ellipse, 5, 5, 4, 3, "#FFFFFF")
|
||||
assert ellipse[5, 1, 3] == 255 and ellipse[1, 5, 3] == 0
|
||||
hollow = pixel.canvas(11, 11)
|
||||
pixel.draw_ellipse(hollow, 5, 5, 4, 3, "#FFFFFF", filled=False, thickness=1)
|
||||
assert hollow[5, 5, 3] == 0 and hollow[5, 1, 3] == 255
|
||||
|
||||
|
||||
def test_capsule_covers_only_its_own_radius():
|
||||
image = pixel.canvas(12, 12)
|
||||
pixel.draw_capsule(image, 3, 6, 8, 6, 3.0, "#FFFFFF")
|
||||
assert image[6, 3, 3] == 255 and image[6, 8, 3] == 255
|
||||
assert image[6, 11, 3] == 0 and image[0, 3, 3] == 0
|
||||
mask = pixel.capsule_mask(12, 12, 3, 6, 3, 6, 5.0)
|
||||
assert mask[6, 3] and not mask[6, 9]
|
||||
|
||||
|
||||
def test_outline_marks_only_the_silhouette_edge():
|
||||
image = pixel.canvas(9, 9)
|
||||
pixel.draw_rect(image, 2, 2, 6, 6, "#FFFFFF")
|
||||
pixel.outline(image, "#000000")
|
||||
assert list(image[2, 2, :3]) == [0, 0, 0]
|
||||
assert list(image[4, 4, :3]) == [255, 255, 255]
|
||||
# Силуэт, упирающийся в край холста, всё равно получает кромку с этой стороны.
|
||||
touching = pixel.edge_mask(pixel.canvas(4, 4, "#FFFFFF"))
|
||||
assert touching[0].all() and touching[:, 0].all() and touching[-1].all()
|
||||
assert not touching[1:3, 1:3].any()
|
||||
|
||||
|
||||
def test_shading_ramp_and_depth_factor():
|
||||
shadow, base, highlight = pixel.ramp("#808080")
|
||||
assert int(shadow[0]) < int(base[0]) < int(highlight[0])
|
||||
assert int(pixel.shade("#FFFFFF", 2.0)[0]) == 255 and int(pixel.shade("#804020", 1.0)[3]) == 255
|
||||
assert pixel.depth_factor(0.0) < pixel.depth_factor(0.5) < pixel.depth_factor(1.0) == 1.0
|
||||
|
||||
|
||||
def test_far_limbs_are_drawn_first_and_darker():
|
||||
near = pixel.Limb(2, 2, 2, 9, 3.0, "#A0A0A0", depth=1.0)
|
||||
far = pixel.Limb(6, 2, 6, 9, 3.0, "#A0A0A0", depth=0.0)
|
||||
assert pixel.sort_by_depth((near, far)) == (far, near)
|
||||
image = pixel.canvas(12, 12)
|
||||
pixel.draw_limbs(image, (near, far))
|
||||
assert int(image[5, 6, 0]) < int(image[5, 2, 0])
|
||||
|
||||
|
||||
def test_overlapping_limbs_let_the_near_one_win():
|
||||
image = pixel.canvas(12, 12)
|
||||
far = pixel.Limb(2, 6, 9, 6, 3.0, "#FFFFFF", depth=0.0)
|
||||
near = pixel.Limb(2, 6, 9, 6, 3.0, "#FFFFFF", depth=1.0)
|
||||
pixel.draw_limbs(image, (far, near))
|
||||
assert int(image[6, 5, 0]) == 255
|
||||
|
||||
|
||||
def test_rim_light_touches_only_the_lit_side():
|
||||
image = pixel.canvas(16, 16)
|
||||
pixel.draw_ellipse(image, 8, 8, 5, 5, "#404040")
|
||||
lit = image.copy()
|
||||
pixel.rim_light(lit, (-1.0, 0.0), "#FFFFFF", strength=1.0)
|
||||
left = int(lit[8, 3, 0]) - int(image[8, 3, 0])
|
||||
right = int(lit[8, 13, 0]) - int(image[8, 13, 0])
|
||||
assert left > 0 and right == 0
|
||||
assert (lit[..., 3] == image[..., 3]).all()
|
||||
with pytest.raises(ValueError):
|
||||
pixel.rim_light(lit, (0.0, 0.0))
|
||||
|
||||
|
||||
def test_noise_uses_only_the_supplied_generator():
|
||||
def painted():
|
||||
image = pixel.canvas(8, 8)
|
||||
pixel.draw_rect(image, 1, 1, 6, 6, "#606060")
|
||||
return image
|
||||
first, second, other = painted(), painted(), painted()
|
||||
pixel.add_noise(first, np.random.default_rng(4))
|
||||
pixel.add_noise(second, np.random.default_rng(4))
|
||||
pixel.add_noise(other, np.random.default_rng(5))
|
||||
assert np.array_equal(first, second) and not np.array_equal(first, other)
|
||||
assert not first[0, 0].any()
|
||||
|
||||
|
||||
def test_flip_keeps_the_pivot_on_the_same_pixel():
|
||||
image = pixel.canvas(6, 3)
|
||||
pixel.set_pixel(image, 1, 1, "#FFFFFF")
|
||||
flipped = pixel.flip_horizontal(image)
|
||||
assert flipped[1, 4, 3] == 255
|
||||
assert pixel.flip_pivot_x(1, 6) == 4
|
||||
assert flipped.flags["C_CONTIGUOUS"]
|
||||
74
tests/test_procedural.py
Normal file
74
tests/test_procedural.py
Normal file
@ -0,0 +1,74 @@
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from spriteforge.backends import BackendContext, create_backend
|
||||
from spriteforge.backends.procedural import generator_names
|
||||
from spriteforge.manifest import AssetSpec
|
||||
from spriteforge.postprocess import build_palette, encode_frames, normalize_asset
|
||||
|
||||
def spec(generator:str,**params)->AssetSpec:
|
||||
return AssetSpec(backend="procedural",generator=generator,params=params,dirs=1,seed=123)
|
||||
|
||||
@pytest.mark.parametrize("name",["floor_tile","wall_tile","decal"])
|
||||
def test_generators_are_deterministic_and_nonempty(name):
|
||||
backend=create_backend("procedural");context=BackendContext(Path.cwd(),"test")
|
||||
a=backend.generate(spec(name),context);b=backend.generate(spec(name),context)
|
||||
assert np.array_equal(a.frames[0].rgba,b.frames[0].rgba)
|
||||
assert np.count_nonzero(a.frames[0].rgba[...,3])>0
|
||||
assert a.frames[0].rgba.dtype==np.uint8
|
||||
|
||||
def test_seed_changes_noise():
|
||||
backend=create_backend("procedural");context=BackendContext(Path.cwd(),"test")
|
||||
a=backend.generate(spec("floor_tile"),context)
|
||||
changed=spec("floor_tile").model_copy(update={"seed":124})
|
||||
b=backend.generate(changed,context)
|
||||
assert not np.array_equal(a.frames[0].rgba,b.frames[0].rgba)
|
||||
|
||||
def test_variants_and_postprocess_streams():
|
||||
raw=create_backend("procedural").generate(spec("decal",variants=3,width=31,height=17),BackendContext(Path.cwd(),"x"))
|
||||
normalized=normalize_asset(raw,"24x12")
|
||||
palette=build_palette([normalized]);frames=encode_frames(normalized,palette,dither=True)
|
||||
assert len(frames)==3 and len(palette)==256
|
||||
assert all(f.width<=24 and f.height<=12 for f in frames)
|
||||
assert all(f.normals_xy is not None and f.depth is not None for f in frames)
|
||||
|
||||
def test_explicit_source_scale_downsamples_small_supersampled_silhouette():
|
||||
raw=create_backend("procedural").generate(spec("decal",width=40,height=20),BackendContext(Path.cwd(),"x"))
|
||||
normalized=normalize_asset(raw,"96x112",0.25)
|
||||
frame=normalized.frames[0]
|
||||
assert frame.rgba.shape[1]<=10 and frame.rgba.shape[0]<=5
|
||||
|
||||
def test_unknown_generator_is_textual():
|
||||
with pytest.raises(ValueError,match="available"):
|
||||
create_backend("procedural").generate(spec("missing"),BackendContext(Path.cwd(),"x"))
|
||||
|
||||
def test_unknown_generator_parameter_is_rejected():
|
||||
with pytest.raises(ValueError,match="heigth"):
|
||||
create_backend("procedural").generate(spec("wall_tile",heigth=20),BackendContext(Path.cwd(),"x"))
|
||||
|
||||
def test_registry_contains_three_generators():
|
||||
assert {"floor_tile","wall_tile","decal"}<=set(generator_names())
|
||||
|
||||
def test_single_frame_generator_still_refuses_extra_directions():
|
||||
turned=spec("floor_tile").model_copy(update={"dirs":4})
|
||||
with pytest.raises(ValueError,match="dirs: 1"):
|
||||
create_backend("procedural").generate(turned,BackendContext(Path.cwd(),"x"))
|
||||
|
||||
# Слепки старых генераторов: расширение бэкенда последовательностями не имеет
|
||||
# права поменять ни один байт уже собранных ассетов.
|
||||
GOLDEN={"floor_tile":("4d1c4610ac48af25d8acdc8f33f6f75416ca493b764dbc45d35afd5d99d38cf5",{"width":32,"height":16,"variants":2}),
|
||||
"wall_tile":("3d89b51db3989e1c9fad63a3f0682d504a8f7a5055a4ab94c19a69351c344096",{"width":32,"top_height":16,"height":24}),
|
||||
"decal":("654dc4cff086dafb1285914e78120055e2b9adbcfd0697011968b6176697c17f",{"width":24,"height":12})}
|
||||
|
||||
@pytest.mark.parametrize("name",sorted(GOLDEN))
|
||||
def test_existing_generators_are_byte_identical(name):
|
||||
digest,params=GOLDEN[name]
|
||||
frozen=AssetSpec(backend="procedural",generator=name,params=params,dirs=1,seed=77,fps=10)
|
||||
asset=create_backend("procedural").generate(frozen,BackendContext(Path.cwd(),"t"))
|
||||
hasher=hashlib.sha256()
|
||||
for frame in asset.frames:
|
||||
hasher.update(frame.rgba.tobytes())
|
||||
hasher.update(str((frame.pivot_x,frame.pivot_y,frame.duration_ms)).encode())
|
||||
assert hasher.hexdigest()==digest
|
||||
78
tests/test_relay.py
Normal file
78
tests/test_relay.py
Normal file
@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
|
||||
import io,json,time
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
|
||||
from PIL import Image
|
||||
import pytest
|
||||
|
||||
from spriteforge.relay.client import RelayClient
|
||||
from spriteforge.relay.server import RelayServer,RelayStore
|
||||
from spriteforge.relay.worker import GpuWorker
|
||||
from spriteforge.studio.models import GenerationRecipe,GenerationRequest
|
||||
from spriteforge.studio.providers import RelayProvider
|
||||
|
||||
|
||||
class FakeComfy(BaseHTTPRequestHandler):
|
||||
def log_message(self,*args):pass
|
||||
def reply(self,value):
|
||||
body=json.dumps(value).encode();self.send_response(200);self.send_header("Content-Type","application/json");self.send_header("Content-Length",str(len(body)));self.end_headers();self.wfile.write(body)
|
||||
def do_POST(self):
|
||||
body=self.rfile.read(int(self.headers.get("Content-Length",0)))
|
||||
if self.path=="/prompt":return self.reply({"prompt_id":"p1"})
|
||||
if self.path=="/upload/image":return self.reply({"name":"input.png"})
|
||||
self.send_error(404)
|
||||
def do_GET(self):
|
||||
if self.path=="/history/p1":return self.reply({"p1":{"outputs":{"7":{"images":[{"filename":"out.png","subfolder":"","type":"output"}]}}}})
|
||||
if self.path.startswith("/view?"):
|
||||
image=Image.new("RGBA",(16,16),(70,20,30,255));out=io.BytesIO();image.save(out,"PNG");data=out.getvalue();self.send_response(200);self.send_header("Content-Length",str(len(data)));self.end_headers();self.wfile.write(data);return
|
||||
if self.path=="/system_stats":return self.reply({"ok":True})
|
||||
self.send_error(404)
|
||||
|
||||
|
||||
def servers(tmp_path):
|
||||
relay=RelayServer(("127.0.0.1",0),RelayStore(tmp_path/"relay"),"client-secret","worker-secret");Thread(target=relay.serve_forever,daemon=True).start()
|
||||
comfy=ThreadingHTTPServer(("127.0.0.1",0),FakeComfy);Thread(target=comfy.serve_forever,daemon=True).start()
|
||||
return relay,comfy
|
||||
|
||||
|
||||
def workflow(path:Path):
|
||||
path.write_text(json.dumps({"1":{"class_type":"Test","inputs":{"p":"{{PROMPT}}","n":"{{NEGATIVE_PROMPT}}","seed":"{{SEED}}","w":"{{WIDTH}}","h":"{{HEIGHT}}"}}}))
|
||||
|
||||
|
||||
def test_relay_auth_blob_lease_and_completion(tmp_path):
|
||||
relay,comfy=servers(tmp_path);base=f"http://127.0.0.1:{relay.server_port}";client=RelayClient(base,"client-secret");worker=RelayClient(base,"worker-secret")
|
||||
try:
|
||||
digest=client.upload(b"hello");job=client.submit({"input":digest});leased=worker.lease("w1",60)
|
||||
assert client.ping_client()["role"]=="client" and worker.ping_worker()["role"]=="worker"
|
||||
assert leased["id"]==job["id"] and leased["attempts"]==1
|
||||
output=worker.upload(b"result");worker.complete(job["id"],"w1",[output])
|
||||
assert client.status(job["id"])["status"]=="succeeded" and client.download(output)==b"result"
|
||||
with pytest.raises(ValueError,match="401"):RelayClient(base,"wrong").status(job["id"])
|
||||
queued=client.submit({"x":1});client.cancel(queued["id"]);assert client.status(queued["id"])["status"]=="cancelled"
|
||||
finally:relay.shutdown();relay.server_close();comfy.shutdown();comfy.server_close()
|
||||
|
||||
|
||||
def test_studio_relay_provider_to_gpu_worker_to_comfyui(tmp_path):
|
||||
relay,comfy=servers(tmp_path);base=f"http://127.0.0.1:{relay.server_port}";flow=tmp_path/"workflow.json";workflow(flow)
|
||||
provider=RelayProvider(base,flow,"client-secret",timeout=5,poll_interval=.01)
|
||||
request=GenerationRequest(project_id="ops",asset_id="agent",shot_id="shot",count=1,
|
||||
recipe=GenerationRecipe(provider="relay",model="test",seed=3,prompt="horror",width=64,height=64))
|
||||
result=[];errors=[]
|
||||
thread=Thread(target=lambda: _capture(provider,request,result,errors));thread.start()
|
||||
worker=GpuWorker(base,"worker-secret",f"http://127.0.0.1:{comfy.server_port}","gpu1",60)
|
||||
try:
|
||||
deadline=time.monotonic()+3
|
||||
while time.monotonic()<deadline:
|
||||
if worker.once():break
|
||||
time.sleep(.01)
|
||||
thread.join(5)
|
||||
assert not errors and result and result[0][0].startswith(b"\x89PNG")
|
||||
finally:relay.shutdown();relay.server_close();comfy.shutdown();comfy.server_close()
|
||||
|
||||
|
||||
def _capture(provider,request,result,errors):
|
||||
try:result.append(provider.generate(request,lambda _:b""))
|
||||
except Exception as error:errors.append(error)
|
||||
221
tests/test_sequence.py
Normal file
221
tests/test_sequence.py
Normal file
@ -0,0 +1,221 @@
|
||||
"""Раскладка кадров многонаправленных ассетов и путь генератора-последовательности."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from spriteforge import pixel
|
||||
from spriteforge.cli import app
|
||||
from spriteforge.backends import BackendContext, RawFrame, create_backend
|
||||
from spriteforge.backends.procedural import (register_generator, register_sequence_generator,
|
||||
sequence_generator_names)
|
||||
from spriteforge.backends.sequence import (build_sequence_asset, direction_centidegrees, duration_from_fps,
|
||||
frame_index, resolve_pivot)
|
||||
from spriteforge.format import Animation, encode_sfa
|
||||
from spriteforge.manifest import AssetSpec
|
||||
from spriteforge.postprocess import build_palette, encode_frames, normalize_asset
|
||||
from spriteforge.reader import decode_sfa
|
||||
|
||||
CONTEXT = BackendContext(Path.cwd(), "walker")
|
||||
ANIMS = (("idle", 2), ("walk", 3))
|
||||
|
||||
|
||||
def marker(duration: int) -> RawFrame:
|
||||
"""Кадр-метка: содержимое неважно, опознаётся по длительности."""
|
||||
rgba = pixel.canvas(4, 4)
|
||||
pixel.draw_rect(rgba, 1, 1, 2, 2, "#C87840")
|
||||
return RawFrame(rgba, 1, 3, duration)
|
||||
|
||||
|
||||
def stamp(animation: int, direction: int, frame: int) -> int:
|
||||
return 1 + 100 * animation + 10 * direction + frame
|
||||
|
||||
|
||||
def sample_frames(direction_count: int) -> dict[str, list[list[RawFrame]]]:
|
||||
return {name: [[marker(stamp(animation, direction, frame)) for frame in range(count)]
|
||||
for direction in range(direction_count)]
|
||||
for animation, (name, count) in enumerate(ANIMS)}
|
||||
|
||||
|
||||
def test_direction_angles_split_the_circle():
|
||||
assert direction_centidegrees(1) == (0,)
|
||||
assert direction_centidegrees(8) == (0, 4500, 9000, 13500, 18000, 22500, 27000, 31500)
|
||||
assert direction_centidegrees(3) == (0, 12000, 24000)
|
||||
with pytest.raises(ValueError):
|
||||
direction_centidegrees(0)
|
||||
|
||||
|
||||
def test_frame_index_is_direction_major_inside_each_animation():
|
||||
assert frame_index(ANIMS, 4, "idle", 0, 0) == 0
|
||||
assert frame_index(ANIMS, 4, "idle", 1, 1) == 3
|
||||
assert frame_index(ANIMS, 4, "walk", 0, 0) == 8
|
||||
assert frame_index(ANIMS, 4, "walk", 3, 2) == 8 + 3 * 3 + 2
|
||||
covered = [frame_index(ANIMS, 4, name, direction, frame)
|
||||
for name, count in ANIMS for direction in range(4) for frame in range(count)]
|
||||
assert sorted(covered) == list(range(20))
|
||||
|
||||
|
||||
def test_frame_index_rejects_impossible_coordinates():
|
||||
with pytest.raises(ValueError, match="unknown animation"):
|
||||
frame_index(ANIMS, 4, "fly", 0, 0)
|
||||
with pytest.raises(ValueError, match="direction 4"):
|
||||
frame_index(ANIMS, 4, "idle", 4, 0)
|
||||
with pytest.raises(ValueError, match="frame 2"):
|
||||
frame_index(ANIMS, 4, "idle", 0, 2)
|
||||
|
||||
|
||||
def test_layout_survives_encoding_and_matches_the_reader():
|
||||
directions = direction_centidegrees(4)
|
||||
asset = build_sequence_asset(sample_frames(4), directions)
|
||||
assert asset.animations == ANIMS and asset.animation == "idle"
|
||||
assert len(asset.frames) == sum(count for _, count in ANIMS) * 4
|
||||
normalized = normalize_asset(asset, None)
|
||||
blob = encode_sfa(encode_frames(normalized, build_palette([normalized])), animation=asset.animation,
|
||||
directions=asset.directions_centidegrees, layer_count=asset.layer_count,
|
||||
animations=tuple(Animation(name, count) for name, count in asset.animations))
|
||||
sfa = decode_sfa(blob)
|
||||
assert sfa.directions == directions
|
||||
assert tuple((name, per_direction) for name, _, per_direction in sfa.animations) == ANIMS
|
||||
for animation, (name, first, per_direction) in enumerate(sfa.animations):
|
||||
for direction in range(len(directions)):
|
||||
for frame in range(per_direction):
|
||||
index = first + direction * per_direction + frame
|
||||
assert index == frame_index(asset.animations, len(directions), name, direction, frame)
|
||||
assert sfa.frames[index].duration_ms == stamp(animation, direction, frame)
|
||||
|
||||
|
||||
def test_build_sequence_asset_rejects_ragged_input():
|
||||
directions = direction_centidegrees(4)
|
||||
with pytest.raises(ValueError, match="direction"):
|
||||
build_sequence_asset(sample_frames(3), directions)
|
||||
ragged = sample_frames(4)
|
||||
ragged["walk"][2] = ragged["walk"][2][:1]
|
||||
with pytest.raises(ValueError, match="same frame count"):
|
||||
build_sequence_asset(ragged, directions)
|
||||
empty = sample_frames(4)
|
||||
empty["idle"] = [[] for _ in directions]
|
||||
with pytest.raises(ValueError, match="no frames"):
|
||||
build_sequence_asset(empty, directions)
|
||||
with pytest.raises(ValueError, match="at least one animation"):
|
||||
build_sequence_asset({}, directions)
|
||||
with pytest.raises(ValueError, match="at least one direction"):
|
||||
build_sequence_asset(sample_frames(0), ())
|
||||
with pytest.raises(TypeError):
|
||||
build_sequence_asset({"idle": [[object()]]}, (0,))
|
||||
with pytest.raises(ValueError, match="layer_orders"):
|
||||
build_sequence_asset({"idle": [[marker(1)]]}, (0,), layer_count=2, layer_orders=[(0, 1), (1, 0)])
|
||||
|
||||
|
||||
def test_resolve_pivot_and_duration():
|
||||
assert resolve_pivot("bottom_center", 8, 12) == (4, 11)
|
||||
assert resolve_pivot("center", 8, 12) == (4, 6)
|
||||
assert resolve_pivot("3,7", 8, 12) == (3, 7)
|
||||
with pytest.raises(ValueError):
|
||||
resolve_pivot("middle", 8, 12)
|
||||
assert duration_from_fps(10) == 100 and duration_from_fps(12) == 83
|
||||
with pytest.raises(ValueError):
|
||||
duration_from_fps(0)
|
||||
|
||||
|
||||
def walker(spec, rng):
|
||||
"""Демонстрационный генератор-последовательность: столбик с шумом."""
|
||||
directions = direction_centidegrees(spec.dirs)
|
||||
duration = duration_from_fps(spec.fps)
|
||||
animations = {}
|
||||
for name in spec.anims or ["idle"]:
|
||||
rows = []
|
||||
for direction in range(len(directions)):
|
||||
frames = []
|
||||
for number in range(2):
|
||||
image = pixel.canvas(8, 12)
|
||||
pixel.draw_capsule(image, 4, 3 + number, 4, 9, 3.0, "#8090A0")
|
||||
pixel.add_noise(image, rng, 6)
|
||||
pivot = resolve_pivot(spec.pivot, image.shape[1], image.shape[0])
|
||||
frames.append(RawFrame(image, pivot[0], pivot[1], duration))
|
||||
rows.append(frames)
|
||||
animations[name] = rows
|
||||
return build_sequence_asset(animations, directions)
|
||||
|
||||
|
||||
def only_idle(spec, rng):
|
||||
directions = direction_centidegrees(spec.dirs)
|
||||
return build_sequence_asset({"idle": [[marker(1)] for _ in directions]}, directions)
|
||||
|
||||
|
||||
def one_direction(spec, rng):
|
||||
return build_sequence_asset({"idle": [[marker(1)]]}, (0,))
|
||||
|
||||
|
||||
register_sequence_generator("test_walker", walker, replace=True)
|
||||
register_sequence_generator("test_only_idle", only_idle, replace=True)
|
||||
register_sequence_generator("test_one_direction", one_direction, replace=True)
|
||||
|
||||
|
||||
def walker_spec(**changes) -> AssetSpec:
|
||||
values = {"backend": "procedural", "generator": "test_walker", "dirs": 8,
|
||||
"anims": ["idle", "walk"], "seed": 5, "fps": 10}
|
||||
values.update(changes)
|
||||
return AssetSpec(**values)
|
||||
|
||||
|
||||
def test_backend_runs_a_sequence_generator_with_eight_directions():
|
||||
asset = create_backend("procedural").generate(walker_spec(), CONTEXT)
|
||||
assert asset.directions_centidegrees == direction_centidegrees(8)
|
||||
assert asset.animations == (("idle", 2), ("walk", 2)) and asset.animation == "idle"
|
||||
assert len(asset.frames) == 2 * 2 * 8
|
||||
assert all(frame.duration_ms == 100 for frame in asset.frames)
|
||||
assert all(frame.pivot_x == 4 and frame.pivot_y == 11 for frame in asset.frames)
|
||||
|
||||
|
||||
def test_sequence_generator_output_depends_only_on_the_seed():
|
||||
backend = create_backend("procedural")
|
||||
first = backend.generate(walker_spec(), CONTEXT)
|
||||
again = backend.generate(walker_spec(), CONTEXT)
|
||||
other = backend.generate(walker_spec(seed=6), CONTEXT)
|
||||
assert all(np.array_equal(a.rgba, b.rgba) for a, b in zip(first.frames, again.frames))
|
||||
assert not np.array_equal(first.frames[0].rgba, other.frames[0].rgba)
|
||||
|
||||
|
||||
def test_sequence_asset_passes_the_normal_postprocess():
|
||||
asset = normalize_asset(create_backend("procedural").generate(walker_spec(dirs=4), CONTEXT), "8x8")
|
||||
frames = encode_frames(asset, build_palette([asset]))
|
||||
assert len(frames) == 2 * 2 * 4
|
||||
assert all(frame.width <= 8 and frame.height <= 8 for frame in frames)
|
||||
|
||||
|
||||
def test_sequence_generator_is_checked_against_the_manifest():
|
||||
backend = create_backend("procedural")
|
||||
with pytest.raises(ValueError, match="dirs: 4"):
|
||||
backend.generate(walker_spec(generator="test_one_direction", dirs=4, anims=[]), CONTEXT)
|
||||
with pytest.raises(ValueError, match="attack"):
|
||||
backend.generate(walker_spec(generator="test_only_idle", dirs=2, anims=["idle", "attack"]), CONTEXT)
|
||||
|
||||
|
||||
def test_cli_builds_a_multi_direction_asset(tmp_path):
|
||||
manifest = tmp_path / "assets.yaml"
|
||||
manifest.write_text("hero:\n backend: procedural\n generator: test_walker\n"
|
||||
" dirs: 8\n fps: 10\n seed: 3\n anims: [idle, walk]\n", encoding="utf-8")
|
||||
cache = tmp_path / "cache"
|
||||
first, second = tmp_path / "out", tmp_path / "out2"
|
||||
runner = CliRunner()
|
||||
for output in (first, second):
|
||||
result = runner.invoke(app, ["build", str(manifest), "--output", str(output), "--cache-dir", str(cache)])
|
||||
assert result.exit_code == 0, result.output
|
||||
sfa = decode_sfa((first / "hero.sfa").read_bytes())
|
||||
assert len(sfa.directions) == 8 and len(sfa.frames) == 2 * 2 * 8
|
||||
assert sfa.animations == (("idle", 0, 2), ("walk", 16, 2))
|
||||
index = json.loads((first / "index.json").read_text())
|
||||
assert index["assets"][0]["animations"] == [{"name": "idle", "first_frame": 0, "frames_per_direction": 2},
|
||||
{"name": "walk", "first_frame": 16, "frames_per_direction": 2}]
|
||||
# Кеш сериализует направления и таблицу анимаций: пересборка обязана дать те же байты.
|
||||
assert (first / "hero.sfa").read_bytes() == (second / "hero.sfa").read_bytes()
|
||||
|
||||
|
||||
def test_names_cannot_collide_across_the_two_registries():
|
||||
assert "test_walker" in sequence_generator_names()
|
||||
with pytest.raises(ValueError):
|
||||
register_sequence_generator("floor_tile", walker)
|
||||
with pytest.raises(ValueError):
|
||||
register_generator("test_walker", lambda params, rng: None)
|
||||
34
tests/test_stage8.py
Normal file
34
tests/test_stage8.py
Normal file
@ -0,0 +1,34 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from spriteforge.codegen import generate_header
|
||||
from spriteforge.palette import encode_sfp
|
||||
from spriteforge.watch import snapshot, write_reload
|
||||
|
||||
|
||||
def test_codegen_emits_stable_ids_and_metadata(tmp_path: Path):
|
||||
build = tmp_path / "build"; build.mkdir()
|
||||
(build / "palettes.sfp").write_bytes(encode_sfp({"default": [(0, 0, 0)] * 256}))
|
||||
(build / "index.json").write_text(json.dumps({"version": 1, "palette": {"file": "palettes.sfp"}, "assets": [{
|
||||
"id": "crypt_wall", "frames": 6, "directions": 1,
|
||||
"animations": [{"name": "idle"}], "streams": {"normals": True, "depth": False}
|
||||
}]}))
|
||||
output = generate_header(build, tmp_path / "generated" / "assets.h")
|
||||
text = output.read_text()
|
||||
assert "SF_ASSET_CRYPT_WALL = UINT32_C(0x" in text
|
||||
assert "SF_ANIM_IDLE" in text
|
||||
assert "SF_PALETTE_DEFAULT" in text
|
||||
assert "{SF_ASSET_CRYPT_WALL, 6, 1, 1, 0}" in text
|
||||
|
||||
|
||||
def test_snapshot_and_reload_signal(tmp_path: Path):
|
||||
source = tmp_path / "asset.png"
|
||||
assert snapshot([source])[source] is None
|
||||
source.write_bytes(b"png")
|
||||
assert snapshot([source])[source] is not None
|
||||
output = tmp_path / "build"
|
||||
write_reload(output, ["z", "a"])
|
||||
payload = json.loads((output / "reload.json").read_text())
|
||||
assert payload["version"] == 1
|
||||
assert payload["assets"] == ["a", "z"]
|
||||
assert isinstance(payload["sequence"], int)
|
||||
14
tests/test_studio_batch.py
Normal file
14
tests/test_studio_batch.py
Normal file
@ -0,0 +1,14 @@
|
||||
from spriteforge.studio.models import Asset
|
||||
from spriteforge.studio.store import ProjectStore
|
||||
|
||||
|
||||
def test_batch_grid_and_asset_settings(tmp_path):
|
||||
store=ProjectStore.create(tmp_path/"studio","ops","Ops")
|
||||
store.add_asset(Asset(id="agent",name="Agent",kind="character",target_width=60,target_height=60))
|
||||
project,created=store.add_shot_grid("agent","walk",8,6)
|
||||
assert len(created)==48 and project.assets[0].directions==8
|
||||
_,again=store.add_shot_grid("agent","walk",8,6)
|
||||
assert not again
|
||||
project=store.update_asset("agent",{"generation_width":768,"fps":10.0,"pivot_y":.92})
|
||||
asset=project.assets[0]
|
||||
assert asset.generation_width==768 and asset.fps==10 and asset.pivot_y==.92
|
||||
16
tests/test_studio_config.py
Normal file
16
tests/test_studio_config.py
Normal file
@ -0,0 +1,16 @@
|
||||
from spriteforge.studio.config import GpuProfile,get_profile,load_config,save_profile
|
||||
|
||||
|
||||
def test_gpu_profiles_do_not_store_secrets(tmp_path):
|
||||
path=tmp_path/"providers.json"
|
||||
save_profile(GpuProfile(name="renderbox",endpoint="https://gpu.invalid",workflow="workflow.json",token_env="SF_GPU_TOKEN"),path)
|
||||
profile=get_profile("renderbox",path)
|
||||
assert profile.endpoint=="https://gpu.invalid" and profile.token_env=="SF_GPU_TOKEN"
|
||||
assert "secret" not in path.read_text().lower()
|
||||
save_profile(GpuProfile(name="renderbox",endpoint="http://new",workflow="new.json"),path)
|
||||
assert len(load_config(path).profiles)==1 and get_profile("renderbox",path).endpoint=="http://new"
|
||||
|
||||
|
||||
def test_relay_profile_roundtrip(tmp_path):
|
||||
path=tmp_path/"providers.json";save_profile(GpuProfile(name="pull",kind="relay",endpoint="https://relay.invalid",workflow="flow.json",token_env="SF_RELAY_CLIENT_TOKEN"),path)
|
||||
assert get_profile("pull",path).kind=="relay"
|
||||
11
tests/test_studio_controls.py
Normal file
11
tests/test_studio_controls.py
Normal file
@ -0,0 +1,11 @@
|
||||
import io
|
||||
from PIL import Image
|
||||
from spriteforge.studio.models import Asset
|
||||
from spriteforge.studio.store import ProjectStore
|
||||
|
||||
|
||||
def test_shot_control_roles_replace_independently(tmp_path):
|
||||
store=ProjectStore.create(tmp_path/"s","ops","Ops");store.add_asset(Asset(id="a",name="A",kind="other",target_width=10,target_height=10));_,shot=store.add_shot("a","idle",0,0)
|
||||
image=Image.new("RGBA",(4,4),(255,255,255,255));out=io.BytesIO();image.save(out,"PNG");media=store.import_png(out.getvalue(),"control")
|
||||
store.attach_control("a",shot.id,media,"control");project=store.attach_control("a",shot.id,media,"mask")
|
||||
assert {x.role for x in project.assets[0].shots[0].controls}=={"control","mask"}
|
||||
29
tests/test_studio_export.py
Normal file
29
tests/test_studio_export.py
Normal file
@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from PIL import Image
|
||||
|
||||
from spriteforge.reader import decode_sfa
|
||||
from spriteforge.studio.export import export_project
|
||||
from spriteforge.studio.models import Asset, GenerationRecipe
|
||||
from spriteforge.studio.store import ProjectStore
|
||||
|
||||
|
||||
def test_approved_shots_export_to_runtime_asset(tmp_path):
|
||||
store = ProjectStore.create(tmp_path/"studio", "ops", "Ops")
|
||||
store.add_asset(Asset(id="agent", name="Agent", kind="character", target_width=60, target_height=60))
|
||||
recipe = GenerationRecipe(provider="test", model="fake", seed=1, prompt="x", width=256, height=256)
|
||||
for direction in range(2):
|
||||
for frame in range(2):
|
||||
_, shot = store.add_shot("agent", "idle", direction, frame)
|
||||
image = Image.new("RGBA", (64, 80), (50+direction*40, 20+frame*40, 30, 255))
|
||||
data=io.BytesIO(); image.save(data,"PNG")
|
||||
media=store.import_png(data.getvalue(),"candidate")
|
||||
_, candidate=store.add_candidate("agent",shot.id,media,recipe)
|
||||
store.decide("agent",shot.id,candidate.id,"approved")
|
||||
files=export_project(store,tmp_path/"build")
|
||||
assert {p.name for p in files} == {"agent.sfa","palettes.sfp","index.json","spriteforge_assets.h","reload.json"}
|
||||
asset=decode_sfa((tmp_path/"build"/"agent.sfa").read_bytes())
|
||||
assert len(asset.frames)==4 and len(asset.directions)==2
|
||||
assert all(f.width<=60 and f.height<=60 for f in asset.frames)
|
||||
assert "SF_ASSET_AGENT" in (tmp_path/"build"/"spriteforge_assets.h").read_text()
|
||||
34
tests/test_studio_jobs.py
Normal file
34
tests/test_studio_jobs.py
Normal file
@ -0,0 +1,34 @@
|
||||
import time
|
||||
|
||||
from spriteforge.studio.jobs import GenerationQueue
|
||||
from spriteforge.studio.models import Asset, GenerationRecipe, GenerationRequest
|
||||
from spriteforge.studio.store import ProjectStore
|
||||
|
||||
|
||||
def test_generation_queue_persists_status_and_candidates(tmp_path):
|
||||
store=ProjectStore.create(tmp_path/"studio","ops","Ops")
|
||||
store.add_asset(Asset(id="agent",name="Agent",kind="character",target_width=60,target_height=60))
|
||||
_,shot=store.add_shot("agent","idle",0,0)
|
||||
request=GenerationRequest(project_id="ops",asset_id="agent",shot_id=shot.id,count=2,
|
||||
recipe=GenerationRecipe(provider="diagnostic",model="test",seed=1,prompt="x",width=64,height=64))
|
||||
queue=GenerationQueue(store);job=queue.submit(request);deadline=time.monotonic()+3
|
||||
while time.monotonic()<deadline:
|
||||
saved=next(x for x in store.load().jobs if x.id==job.id)
|
||||
if saved.status not in {"queued","running"}:break
|
||||
time.sleep(.01)
|
||||
queue.close()
|
||||
assert saved.status=="succeeded" and len(saved.candidate_ids)==2
|
||||
assert len(store.load().assets[0].shots[0].candidates)==2
|
||||
|
||||
|
||||
def test_cancel_retry_and_interrupted_recovery(tmp_path):
|
||||
store=ProjectStore.create(tmp_path/"studio","ops","Ops");store.add_asset(Asset(id="agent",name="Agent",kind="character",target_width=60,target_height=60));_,shot=store.add_shot("agent","idle",0,0)
|
||||
request=GenerationRequest(project_id="ops",asset_id="agent",shot_id=shot.id,count=1,recipe=GenerationRecipe(provider="missing",model="x",seed=1,prompt="x",width=64,height=64))
|
||||
job=store.create_job(request);store.update_job(job.id,status="running")
|
||||
queue=GenerationQueue(store)
|
||||
assert store.get_job(job.id).status=="failed" and "stopped" in store.get_job(job.id).error
|
||||
retry=queue.retry(job.id);deadline=time.monotonic()+2
|
||||
while time.monotonic()<deadline and store.get_job(retry.id).status in {"queued","running"}:time.sleep(.01)
|
||||
assert store.get_job(retry.id).status=="failed"
|
||||
queued=store.create_job(request);queue.cancel(queued.id);assert store.get_job(queued.id).status=="cancelled"
|
||||
queue.close()
|
||||
13
tests/test_studio_layers.py
Normal file
13
tests/test_studio_layers.py
Normal file
@ -0,0 +1,13 @@
|
||||
from spriteforge.studio.export import export_project
|
||||
from spriteforge.studio.models import Asset
|
||||
from spriteforge.studio.store import ProjectStore
|
||||
import pytest
|
||||
|
||||
|
||||
def test_layer_group_rejects_unsynchronized_grids(tmp_path):
|
||||
store=ProjectStore.create(tmp_path/"s","ops","Ops")
|
||||
for asset_id in ("body","weapon"):
|
||||
store.add_asset(Asset(id=asset_id,name=asset_id,kind="character",target_width=60,target_height=60,layer_group="agent",layer_order=0 if asset_id=="body" else 1))
|
||||
store.add_shot_grid("body","idle",8,4);store.add_shot_grid("weapon","idle",8,3)
|
||||
with pytest.raises(ValueError,match="unsynchronized"):
|
||||
export_project(store,tmp_path/"out")
|
||||
9
tests/test_studio_library_ops.py
Normal file
9
tests/test_studio_library_ops.py
Normal file
@ -0,0 +1,9 @@
|
||||
from spriteforge.studio.models import Asset
|
||||
from spriteforge.studio.store import ProjectStore
|
||||
|
||||
|
||||
def test_clone_keeps_grid_and_refs_but_not_candidates(tmp_path):
|
||||
store=ProjectStore.create(tmp_path/"s","ops","Ops");store.add_asset(Asset(id="body",name="Body",kind="character",target_width=60,target_height=60,layer_group="agent"));store.add_shot_grid("body","walk",8,4)
|
||||
project=store.clone_asset("body","armor","Armor");clone=next(a for a in project.assets if a.id=="armor")
|
||||
assert len(clone.shots)==32 and all(not s.candidates for s in clone.shots) and clone.layer_group=="agent"
|
||||
project=store.delete_asset("body");assert [a.id for a in project.assets]==["armor"]
|
||||
13
tests/test_studio_processing.py
Normal file
13
tests/test_studio_processing.py
Normal file
@ -0,0 +1,13 @@
|
||||
import io
|
||||
from PIL import Image
|
||||
from spriteforge.studio.export import compile_asset
|
||||
from spriteforge.studio.models import Asset,GenerationRecipe
|
||||
from spriteforge.studio.store import ProjectStore
|
||||
|
||||
|
||||
def test_candidate_chroma_key_is_applied_before_export(tmp_path):
|
||||
store=ProjectStore.create(tmp_path/"s","ops","Ops");store.add_asset(Asset(id="item",name="Item",kind="item",target_width=16,target_height=16));_,shot=store.add_shot("item","still",0,0)
|
||||
image=Image.new("RGBA",(10,10),(255,0,255,255));image.paste((20,30,40,255),(4,4,6,6));out=io.BytesIO();image.save(out,"PNG")
|
||||
media=store.import_png(out.getvalue(),"candidate");recipe=GenerationRecipe(provider="x",model="x",seed=0,prompt="x",width=64,height=64);_,candidate=store.add_candidate("item",shot.id,media,recipe)
|
||||
store.update_candidate("item",shot.id,candidate.id,{"process":{"alpha_threshold":128,"chroma_key":"#FF00FF","chroma_tolerance":0}});store.decide("item",shot.id,candidate.id,"approved")
|
||||
raw=compile_asset(store,store.load().assets[0]);assert raw.frames[0].rgba.shape[:2]==(2,2)
|
||||
10
tests/test_studio_providers.py
Normal file
10
tests/test_studio_providers.py
Normal file
@ -0,0 +1,10 @@
|
||||
from spriteforge.studio.models import GenerationRecipe, GenerationRequest
|
||||
from spriteforge.studio.providers import create_provider, provider_names
|
||||
|
||||
|
||||
def test_diagnostic_provider_exercises_batch_without_gpu():
|
||||
request=GenerationRequest(project_id="p",asset_id="agent",shot_id="shot_1",count=3,
|
||||
recipe=GenerationRecipe(provider="diagnostic",model="test",seed=9,prompt="x",width=96,height=80))
|
||||
output=create_provider("diagnostic").generate(request,lambda _: b"")
|
||||
assert len(output)==3 and all(blob.startswith(b"\x89PNG") for blob in output)
|
||||
assert "diagnostic" in provider_names()
|
||||
14
tests/test_studio_quality.py
Normal file
14
tests/test_studio_quality.py
Normal file
@ -0,0 +1,14 @@
|
||||
import io
|
||||
from PIL import Image
|
||||
from spriteforge.studio.quality import analyze_candidate
|
||||
|
||||
|
||||
def png(box):
|
||||
image=Image.new("RGBA",(32,32),(0,0,0,0));image.paste((100,120,130,255),box);out=io.BytesIO();image.save(out,"PNG");return out.getvalue()
|
||||
|
||||
|
||||
def test_candidate_metrics_measure_silhouette_consistency():
|
||||
same=analyze_candidate(png((8,8,24,24)),png((8,8,24,24)))
|
||||
shifted=analyze_candidate(png((8,8,24,24)),png((16,8,32,24)))
|
||||
assert same.opaque_fraction==.25 and same.neighbor_silhouette_iou==1
|
||||
assert shifted.neighbor_silhouette_iou<.5
|
||||
12
tests/test_studio_revisions.py
Normal file
12
tests/test_studio_revisions.py
Normal file
@ -0,0 +1,12 @@
|
||||
from spriteforge.studio.models import Asset
|
||||
from spriteforge.studio.store import ProjectStore
|
||||
|
||||
|
||||
def test_every_save_is_recoverable_by_content_hash(tmp_path):
|
||||
store=ProjectStore.create(tmp_path/"s","ops","Ops")
|
||||
store.add_asset(Asset(id="agent",name="Agent",kind="character",target_width=60,target_height=60))
|
||||
revisions=store.revisions();assert revisions
|
||||
empty=next(item for item in revisions if item["assets"]==0)
|
||||
restored=store.restore_revision(empty["hash"])
|
||||
assert not restored.assets and restored.events[-1].action=="project.restored"
|
||||
assert any(item["assets"]==1 for item in store.revisions())
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user