Build SpriteForge asset studio and pull GPU relay

This commit is contained in:
z.kirill 2026-08-16 03:57:13 +03:00
commit ebf40a7451
103 changed files with 6908 additions and 0 deletions

23
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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.

View 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
View 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
View 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
View 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
View 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.

View 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
View 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.

View 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.

View 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]}
}
}

View 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]

View 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.

View 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
View 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
View 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
View 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"]

View File

@ -0,0 +1,3 @@
"""SpriteForge asset pipeline."""
__version__ = "0.1.0"

View 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")))

View 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"]

View 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."""

View 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)

View 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()

View 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)

View 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)

View 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

View 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)

View 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))

View 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
View 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
View 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
View 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()

View 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
View 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))

View 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
View 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)

View 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
View 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)

View 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
View 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)

View File

@ -0,0 +1 @@
"""Asynchronous pull relay for GPU workers behind NAT."""

View 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

View 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()

View 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

View File

@ -0,0 +1,6 @@
"""Human-facing SpriteForge asset authoring studio."""
from .models import GenerationRequest, Project
from .store import ProjectStore
__all__ = ["GenerationRequest", "Project", "ProjectStore"]

View 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)

View 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)

View 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))

View 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)

View 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

View 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)

View 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()

View 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

View 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)"

View 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 (01)<input id="pivotX" type="number" min="0" max="1" step=".01" value="${a.pivot_x}"></label><label class="field">Pivot Y (01)<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
View 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
View File

@ -0,0 +1 @@

20
tests/c99_smoke.c Normal file
View 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
View 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
View 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")

View 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
View 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;
}

View 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"

View 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")

View 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
View 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

View 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
View 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

View 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
View 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

View 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
View 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])

View 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
View 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
View 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
View 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
View 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
View 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)

View 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

View 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"

View 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"}

View 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
View 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()

View 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")

View 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"]

View 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)

View 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()

View 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

View 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