From ebf40a7451353b9858b70b5e2f0532888c56be1b Mon Sep 17 00:00:00 2001 From: "z.kirill" Date: Sun, 16 Aug 2026 03:57:13 +0300 Subject: [PATCH] Build SpriteForge asset studio and pull GPU relay --- .gitignore | 23 ++ CLAUDE.md | 8 + CMakeLists.txt | 31 ++ README.md | 39 +++ deploy/relay/Caddyfile | 7 + deploy/relay/Dockerfile | 8 + deploy/relay/compose.yaml | 29 ++ docs/blender-backend.md | 77 +++++ docs/codegen-watch.md | 20 ++ docs/external-gpu.md | 48 +++ docs/format-v1.md | 44 +++ docs/game-integration.md | 35 +++ docs/import-composite.md | 22 ++ docs/incremental-builds.md | 31 ++ docs/introspection.md | 34 +++ docs/manifest-v1.md | 56 ++++ docs/procedural.md | 137 +++++++++ docs/pull-relay.md | 75 +++++ docs/studio-architecture.md | 50 ++++ docs/studio.md | 52 ++++ examples/comfyui/README.md | 22 ++ examples/comfyui/txt2img_api.json | 41 +++ examples/manifests/showcase.yaml | 20 ++ examples/raylib_cpp17/README.md | 25 ++ examples/raylib_cpp17/main.cpp | 26 ++ include/sfa.h | 159 ++++++++++ include/sfa.hpp | 17 ++ pyproject.toml | 25 ++ src/spriteforge/__init__.py | 3 + src/spriteforge/backend_registry.py | 44 +++ src/spriteforge/backends/__init__.py | 8 + src/spriteforge/backends/base.py | 64 ++++ src/spriteforge/backends/blender.py | 153 ++++++++++ src/spriteforge/backends/blender_driver.py | 156 ++++++++++ src/spriteforge/backends/composite.py | 48 +++ src/spriteforge/backends/import_backend.py | 68 +++++ src/spriteforge/backends/layering.py | 62 ++++ src/spriteforge/backends/procedural.py | 169 +++++++++++ src/spriteforge/backends/registry.py | 20 ++ src/spriteforge/backends/sequence.py | 148 +++++++++ src/spriteforge/build.py | 116 +++++++ src/spriteforge/cache.py | 102 +++++++ src/spriteforge/cli.py | 260 ++++++++++++++++ src/spriteforge/codegen.py | 60 ++++ src/spriteforge/format.py | 151 ++++++++++ src/spriteforge/introspection.py | 182 +++++++++++ src/spriteforge/manifest.py | 241 +++++++++++++++ src/spriteforge/palette.py | 64 ++++ src/spriteforge/pixel.py | 332 +++++++++++++++++++++ src/spriteforge/postprocess.py | 105 +++++++ src/spriteforge/reader.py | 79 +++++ src/spriteforge/relay/__init__.py | 1 + src/spriteforge/relay/client.py | 54 ++++ src/spriteforge/relay/server.py | 182 +++++++++++ src/spriteforge/relay/worker.py | 65 ++++ src/spriteforge/studio/__init__.py | 6 + src/spriteforge/studio/config.py | 61 ++++ src/spriteforge/studio/export.py | 100 +++++++ src/spriteforge/studio/jobs.py | 67 +++++ src/spriteforge/studio/models.py | 170 +++++++++++ src/spriteforge/studio/providers.py | 242 +++++++++++++++ src/spriteforge/studio/quality.py | 29 ++ src/spriteforge/studio/server.py | 186 ++++++++++++ src/spriteforge/studio/store.py | 319 ++++++++++++++++++++ src/spriteforge/studio/validation.py | 63 ++++ src/spriteforge/studio/web/index.html | 57 ++++ src/spriteforge/watch.py | 59 ++++ tests/.gitkeep | 1 + tests/c99_smoke.c | 20 ++ tests/cpp17_smoke.cpp | 8 + tests/make_fixture.py | 9 + tests/make_palette_fixture.py | 5 + tests/sfp_c99_smoke.c | 12 + tests/test_backend_registry.py | 13 + tests/test_blender_backend.py | 109 +++++++ tests/test_build_stage3.py | 47 +++ tests/test_cli.py | 21 ++ tests/test_comfyui_provider.py | 58 ++++ tests/test_format.py | 44 +++ tests/test_import_composite.py | 55 ++++ tests/test_incremental.py | 84 ++++++ tests/test_introspection.py | 78 +++++ tests/test_manifest.py | 61 ++++ tests/test_manifest_version.py | 7 + tests/test_pixel.py | 142 +++++++++ tests/test_procedural.py | 74 +++++ tests/test_relay.py | 78 +++++ tests/test_sequence.py | 221 ++++++++++++++ tests/test_stage8.py | 34 +++ tests/test_studio_batch.py | 14 + tests/test_studio_config.py | 16 + tests/test_studio_controls.py | 11 + tests/test_studio_export.py | 29 ++ tests/test_studio_jobs.py | 34 +++ tests/test_studio_layers.py | 13 + tests/test_studio_library_ops.py | 9 + tests/test_studio_processing.py | 13 + tests/test_studio_providers.py | 10 + tests/test_studio_quality.py | 14 + tests/test_studio_revisions.py | 12 + tests/test_studio_server.py | 29 ++ tests/test_studio_store.py | 55 ++++ tests/test_studio_validation.py | 11 + 103 files changed, 6908 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100644 deploy/relay/Caddyfile create mode 100644 deploy/relay/Dockerfile create mode 100644 deploy/relay/compose.yaml create mode 100644 docs/blender-backend.md create mode 100644 docs/codegen-watch.md create mode 100644 docs/external-gpu.md create mode 100644 docs/format-v1.md create mode 100644 docs/game-integration.md create mode 100644 docs/import-composite.md create mode 100644 docs/incremental-builds.md create mode 100644 docs/introspection.md create mode 100644 docs/manifest-v1.md create mode 100644 docs/procedural.md create mode 100644 docs/pull-relay.md create mode 100644 docs/studio-architecture.md create mode 100644 docs/studio.md create mode 100644 examples/comfyui/README.md create mode 100644 examples/comfyui/txt2img_api.json create mode 100644 examples/manifests/showcase.yaml create mode 100644 examples/raylib_cpp17/README.md create mode 100644 examples/raylib_cpp17/main.cpp create mode 100644 include/sfa.h create mode 100644 include/sfa.hpp create mode 100644 pyproject.toml create mode 100644 src/spriteforge/__init__.py create mode 100644 src/spriteforge/backend_registry.py create mode 100644 src/spriteforge/backends/__init__.py create mode 100644 src/spriteforge/backends/base.py create mode 100644 src/spriteforge/backends/blender.py create mode 100644 src/spriteforge/backends/blender_driver.py create mode 100644 src/spriteforge/backends/composite.py create mode 100644 src/spriteforge/backends/import_backend.py create mode 100644 src/spriteforge/backends/layering.py create mode 100644 src/spriteforge/backends/procedural.py create mode 100644 src/spriteforge/backends/registry.py create mode 100644 src/spriteforge/backends/sequence.py create mode 100644 src/spriteforge/build.py create mode 100644 src/spriteforge/cache.py create mode 100644 src/spriteforge/cli.py create mode 100644 src/spriteforge/codegen.py create mode 100644 src/spriteforge/format.py create mode 100644 src/spriteforge/introspection.py create mode 100644 src/spriteforge/manifest.py create mode 100644 src/spriteforge/palette.py create mode 100644 src/spriteforge/pixel.py create mode 100644 src/spriteforge/postprocess.py create mode 100644 src/spriteforge/reader.py create mode 100644 src/spriteforge/relay/__init__.py create mode 100644 src/spriteforge/relay/client.py create mode 100644 src/spriteforge/relay/server.py create mode 100644 src/spriteforge/relay/worker.py create mode 100644 src/spriteforge/studio/__init__.py create mode 100644 src/spriteforge/studio/config.py create mode 100644 src/spriteforge/studio/export.py create mode 100644 src/spriteforge/studio/jobs.py create mode 100644 src/spriteforge/studio/models.py create mode 100644 src/spriteforge/studio/providers.py create mode 100644 src/spriteforge/studio/quality.py create mode 100644 src/spriteforge/studio/server.py create mode 100644 src/spriteforge/studio/store.py create mode 100644 src/spriteforge/studio/validation.py create mode 100644 src/spriteforge/studio/web/index.html create mode 100644 src/spriteforge/watch.py create mode 100644 tests/.gitkeep create mode 100644 tests/c99_smoke.c create mode 100644 tests/cpp17_smoke.cpp create mode 100644 tests/make_fixture.py create mode 100644 tests/make_palette_fixture.py create mode 100644 tests/sfp_c99_smoke.c create mode 100644 tests/test_backend_registry.py create mode 100644 tests/test_blender_backend.py create mode 100644 tests/test_build_stage3.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_comfyui_provider.py create mode 100644 tests/test_format.py create mode 100644 tests/test_import_composite.py create mode 100644 tests/test_incremental.py create mode 100644 tests/test_introspection.py create mode 100644 tests/test_manifest.py create mode 100644 tests/test_manifest_version.py create mode 100644 tests/test_pixel.py create mode 100644 tests/test_procedural.py create mode 100644 tests/test_relay.py create mode 100644 tests/test_sequence.py create mode 100644 tests/test_stage8.py create mode 100644 tests/test_studio_batch.py create mode 100644 tests/test_studio_config.py create mode 100644 tests/test_studio_controls.py create mode 100644 tests/test_studio_export.py create mode 100644 tests/test_studio_jobs.py create mode 100644 tests/test_studio_layers.py create mode 100644 tests/test_studio_library_ops.py create mode 100644 tests/test_studio_processing.py create mode 100644 tests/test_studio_providers.py create mode 100644 tests/test_studio_quality.py create mode 100644 tests/test_studio_revisions.py create mode 100644 tests/test_studio_server.py create mode 100644 tests/test_studio_store.py create mode 100644 tests/test_studio_validation.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f6a784 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f4769e4 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..15bf075 --- /dev/null +++ b/CMakeLists.txt @@ -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() diff --git a/README.md b/README.md new file mode 100644 index 0000000..c71049b --- /dev/null +++ b/README.md @@ -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`. diff --git a/deploy/relay/Caddyfile b/deploy/relay/Caddyfile new file mode 100644 index 0000000..50a51a5 --- /dev/null +++ b/deploy/relay/Caddyfile @@ -0,0 +1,7 @@ +{$RELAY_DOMAIN} { + encode zstd gzip + reverse_proxy relay:8787 + request_body { + max_size 64MB + } +} diff --git a/deploy/relay/Dockerfile b/deploy/relay/Dockerfile new file mode 100644 index 0000000..fbfebd9 --- /dev/null +++ b/deploy/relay/Dockerfile @@ -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"] diff --git a/deploy/relay/compose.yaml b/deploy/relay/compose.yaml new file mode 100644 index 0000000..8b06c4f --- /dev/null +++ b/deploy/relay/compose.yaml @@ -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: diff --git a/docs/blender-backend.md b/docs/blender-backend.md new file mode 100644 index 0000000..53d3dde --- /dev/null +++ b/docs/blender-backend.md @@ -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. diff --git a/docs/codegen-watch.md b/docs/codegen-watch.md new file mode 100644 index 0000000..0137898 --- /dev/null +++ b/docs/codegen-watch.md @@ -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. diff --git a/docs/external-gpu.md b/docs/external-gpu.md new file mode 100644 index 0000000..f95b62f --- /dev/null +++ b/docs/external-gpu.md @@ -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. diff --git a/docs/format-v1.md b/docs/format-v1.md new file mode 100644 index 0000000..84afcca --- /dev/null +++ b/docs/format-v1.md @@ -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. diff --git a/docs/game-integration.md b/docs/game-integration.md new file mode 100644 index 0000000..fd66ec5 --- /dev/null +++ b/docs/game-integration.md @@ -0,0 +1,35 @@ +# Game integration + +Studio Export writes: + +```text +exports/ + .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. diff --git a/docs/import-composite.md b/docs/import-composite.md new file mode 100644 index 0000000..17993e1 --- /dev/null +++ b/docs/import-composite.md @@ -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. diff --git a/docs/incremental-builds.md b/docs/incremental-builds.md new file mode 100644 index 0000000..50b65d9 --- /dev/null +++ b/docs/incremental-builds.md @@ -0,0 +1,31 @@ +# Incremental builds and cache + +SpriteForge uses two immutable content-addressed cache layers under `.sfcache/`: + +- `raw/.npz` stores expensive backend RGBA output; +- `artifacts/.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. diff --git a/docs/introspection.md b/docs/introspection.md new file mode 100644 index 0000000..cd5b879 --- /dev/null +++ b/docs/introspection.md @@ -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. diff --git a/docs/manifest-v1.md b/docs/manifest-v1.md new file mode 100644 index 0000000..90587c4 --- /dev/null +++ b/docs/manifest-v1.md @@ -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. diff --git a/docs/procedural.md b/docs/procedural.md new file mode 100644 index 0000000..dc09c40 --- /dev/null +++ b/docs/procedural.md @@ -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`. diff --git a/docs/pull-relay.md b/docs/pull-relay.md new file mode 100644 index 0000000..57ec920 --- /dev/null +++ b/docs/pull-relay.md @@ -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. diff --git a/docs/studio-architecture.md b/docs/studio-architecture.md new file mode 100644 index 0000000..3f46172 --- /dev/null +++ b/docs/studio-architecture.md @@ -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/.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. diff --git a/docs/studio.md b/docs/studio.md new file mode 100644 index 0000000..93ef926 --- /dev/null +++ b/docs/studio.md @@ -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. diff --git a/examples/comfyui/README.md b/examples/comfyui/README.md new file mode 100644 index 0000000..bd7a3fb --- /dev/null +++ b/examples/comfyui/README.md @@ -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. diff --git a/examples/comfyui/txt2img_api.json b/examples/comfyui/txt2img_api.json new file mode 100644 index 0000000..8e40d13 --- /dev/null +++ b/examples/comfyui/txt2img_api.json @@ -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]} + } +} diff --git a/examples/manifests/showcase.yaml b/examples/manifests/showcase.yaml new file mode 100644 index 0000000..b42e5a7 --- /dev/null +++ b/examples/manifests/showcase.yaml @@ -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] diff --git a/examples/raylib_cpp17/README.md b/examples/raylib_cpp17/README.md new file mode 100644 index 0000000..e382093 --- /dev/null +++ b/examples/raylib_cpp17/README.md @@ -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. diff --git a/examples/raylib_cpp17/main.cpp b/examples/raylib_cpp17/main.cpp new file mode 100644 index 0000000..149a6ae --- /dev/null +++ b/examples/raylib_cpp17/main.cpp @@ -0,0 +1,26 @@ +#include "sfa.hpp" +#if __has_include("spriteforge_assets.h") +#include "spriteforge_assets.h" +#endif +#include +#include +#include +#include + +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 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 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; +} diff --git a/include/sfa.h b/include/sfa.h new file mode 100644 index 0000000..59726f3 --- /dev/null +++ b/include/sfa.h @@ -0,0 +1,159 @@ +#ifndef SPRITEFORGE_SFA_H +#define SPRITEFORGE_SFA_H + +#include +#include +#include +#include +#include +#include + +#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(absolutestrings_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.strideoffset[0])return SFA_BAD_ARGUMENT; + base=v->data+f->offset[0]; table=4u*((uint32_t)f->height+1u); if(f->size[0]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=0&&dx0)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.strideoffset[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=0&&dx0?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 diff --git a/include/sfa.hpp b/include/sfa.hpp new file mode 100644 index 0000000..38f7c55 --- /dev/null +++ b/include/sfa.hpp @@ -0,0 +1,17 @@ +#pragma once +#include "sfa.h" +#include +#include + +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; } +}; +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4eaadf6 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/src/spriteforge/__init__.py b/src/spriteforge/__init__.py new file mode 100644 index 0000000..33bef0a --- /dev/null +++ b/src/spriteforge/__init__.py @@ -0,0 +1,3 @@ +"""SpriteForge asset pipeline.""" + +__version__ = "0.1.0" diff --git a/src/spriteforge/backend_registry.py b/src/spriteforge/backend_registry.py new file mode 100644 index 0000000..04f72ef --- /dev/null +++ b/src/spriteforge/backend_registry.py @@ -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"))) diff --git a/src/spriteforge/backends/__init__.py b/src/spriteforge/backends/__init__.py new file mode 100644 index 0000000..62819b7 --- /dev/null +++ b/src/spriteforge/backends/__init__.py @@ -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"] diff --git a/src/spriteforge/backends/base.py b/src/spriteforge/backends/base.py new file mode 100644 index 0000000..ab72406 --- /dev/null +++ b/src/spriteforge/backends/base.py @@ -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.""" diff --git a/src/spriteforge/backends/blender.py b/src/spriteforge/backends/blender.py new file mode 100644 index 0000000..b770db1 --- /dev/null +++ b/src/spriteforge/backends/blender.py @@ -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 "")[-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) diff --git a/src/spriteforge/backends/blender_driver.py b/src/spriteforge/backends/blender_driver.py new file mode 100644 index 0000000..ed41534 --- /dev/null +++ b/src/spriteforge/backends/blender_driver.py @@ -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() diff --git a/src/spriteforge/backends/composite.py b/src/spriteforge/backends/composite.py new file mode 100644 index 0000000..5904002 --- /dev/null +++ b/src/spriteforge/backends/composite.py @@ -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) diff --git a/src/spriteforge/backends/import_backend.py b/src/spriteforge/backends/import_backend.py new file mode 100644 index 0000000..9736a9a --- /dev/null +++ b/src/spriteforge/backends/import_backend.py @@ -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) diff --git a/src/spriteforge/backends/layering.py b/src/spriteforge/backends/layering.py new file mode 100644 index 0000000..45d6c98 --- /dev/null +++ b/src/spriteforge/backends/layering.py @@ -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 diff --git a/src/spriteforge/backends/procedural.py b/src/spriteforge/backends/procedural.py new file mode 100644 index 0000000..008278b --- /dev/null +++ b/src/spriteforge/backends/procedural.py @@ -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 ''}") 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) diff --git a/src/spriteforge/backends/registry.py b/src/spriteforge/backends/registry.py new file mode 100644 index 0000000..67114f9 --- /dev/null +++ b/src/spriteforge/backends/registry.py @@ -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)) diff --git a/src/spriteforge/backends/sequence.py b/src/spriteforge/backends/sequence.py new file mode 100644 index 0000000..d4012c9 --- /dev/null +++ b/src/spriteforge/backends/sequence.py @@ -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 "" + 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 diff --git a/src/spriteforge/build.py b/src/spriteforge/build.py new file mode 100644 index 0000000..bc279c7 --- /dev/null +++ b/src/spriteforge/build.py @@ -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) diff --git a/src/spriteforge/cache.py b/src/spriteforge/cache.py new file mode 100644 index 0000000..6758599 --- /dev/null +++ b/src/spriteforge/cache.py @@ -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) diff --git a/src/spriteforge/cli.py b/src/spriteforge/cli.py new file mode 100644 index 0000000..08a001a --- /dev/null +++ b/src/spriteforge/cli.py @@ -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() diff --git a/src/spriteforge/codegen.py b/src/spriteforge/codegen.py new file mode 100644 index 0000000..fdc18d1 --- /dev/null +++ b/src/spriteforge/codegen.py @@ -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 ", "", + "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 diff --git a/src/spriteforge/format.py b/src/spriteforge/format.py new file mode 100644 index 0000000..77fecbe --- /dev/null +++ b/src/spriteforge/format.py @@ -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(" 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(" 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)) diff --git a/src/spriteforge/introspection.py b/src/spriteforge/introspection.py new file mode 100644 index 0000000..e9cfaf6 --- /dev/null +++ b/src/spriteforge/introspection.py @@ -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.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") diff --git a/src/spriteforge/manifest.py b/src/spriteforge/manifest.py new file mode 100644 index 0000000..40653b1 --- /dev/null +++ b/src/spriteforge/manifest.py @@ -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) diff --git a/src/spriteforge/palette.py b/src/spriteforge/palette.py new file mode 100644 index 0000000..58e82d4 --- /dev/null +++ b/src/spriteforge/palette.py @@ -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("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)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)) diff --git a/src/spriteforge/pixel.py b/src/spriteforge/pixel.py new file mode 100644 index 0000000..dc0b3a9 --- /dev/null +++ b/src/spriteforge/pixel.py @@ -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) diff --git a/src/spriteforge/postprocess.py b/src/spriteforge/postprocess.py new file mode 100644 index 0000000..738353d --- /dev/null +++ b/src/spriteforge/postprocess.py @@ -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) diff --git a/src/spriteforge/reader.py b/src/spriteforge/reader.py new file mode 100644 index 0000000..7bb8352 --- /dev/null +++ b/src/spriteforge/reader.py @@ -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)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("w or end-pos SfaFile: + if len(data)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) diff --git a/src/spriteforge/relay/__init__.py b/src/spriteforge/relay/__init__.py new file mode 100644 index 0000000..d1cfaec --- /dev/null +++ b/src/spriteforge/relay/__init__.py @@ -0,0 +1 @@ +"""Asynchronous pull relay for GPU workers behind NAT.""" diff --git a/src/spriteforge/relay/client.py b/src/spriteforge/relay/client.py new file mode 100644 index 0000000..289fec3 --- /dev/null +++ b/src/spriteforge/relay/client.py @@ -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()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 diff --git a/src/spriteforge/relay/server.py b/src/spriteforge/relay/server.py new file mode 100644 index 0000000..448c6db --- /dev/null +++ b/src/spriteforge/relay/server.py @@ -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_untildict: + 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 updatedNone: + 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() diff --git a/src/spriteforge/relay/worker.py b/src/spriteforge/relay/worker.py new file mode 100644 index 0000000..e2e959f --- /dev/null +++ b/src/spriteforge/relay/worker.py @@ -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 diff --git a/src/spriteforge/studio/__init__.py b/src/spriteforge/studio/__init__.py new file mode 100644 index 0000000..9a14237 --- /dev/null +++ b/src/spriteforge/studio/__init__.py @@ -0,0 +1,6 @@ +"""Human-facing SpriteForge asset authoring studio.""" + +from .models import GenerationRequest, Project +from .store import ProjectStore + +__all__ = ["GenerationRequest", "Project", "ProjectStore"] diff --git a/src/spriteforge/studio/config.py b/src/spriteforge/studio/config.py new file mode 100644 index 0000000..e2103a0 --- /dev/null +++ b/src/spriteforge/studio/config.py @@ -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) diff --git a/src/spriteforge/studio/export.py b/src/spriteforge/studio/export.py new file mode 100644 index 0000000..727c430 --- /dev/null +++ b/src/spriteforge/studio/export.py @@ -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) diff --git a/src/spriteforge/studio/jobs.py b/src/spriteforge/studio/jobs.py new file mode 100644 index 0000000..3c7c275 --- /dev/null +++ b/src/spriteforge/studio/jobs.py @@ -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)) diff --git a/src/spriteforge/studio/models.py b/src/spriteforge/studio/models.py new file mode 100644 index 0000000..edaf834 --- /dev/null +++ b/src/spriteforge/studio/models.py @@ -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) diff --git a/src/spriteforge/studio/providers.py b/src/spriteforge/studio/providers.py new file mode 100644 index 0000000..b76b309 --- /dev/null +++ b/src/spriteforge/studio/providers.py @@ -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 diff --git a/src/spriteforge/studio/quality.py b/src/spriteforge/studio/quality.py new file mode 100644 index 0000000..5a58557 --- /dev/null +++ b/src/spriteforge/studio/quality.py @@ -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) diff --git a/src/spriteforge/studio/server.py b/src/spriteforge/studio/server.py new file mode 100644 index 0000000..4a45493 --- /dev/null +++ b/src/spriteforge/studio/server.py @@ -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() diff --git a/src/spriteforge/studio/store.py b/src/spriteforge/studio/store.py new file mode 100644 index 0000000..5df7f1a --- /dev/null +++ b/src/spriteforge/studio/store.py @@ -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 diff --git a/src/spriteforge/studio/validation.py b/src/spriteforge/studio/validation.py new file mode 100644 index 0000000..eb8f021 --- /dev/null +++ b/src/spriteforge/studio/validation.py @@ -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_widthstr: + 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)" diff --git a/src/spriteforge/studio/web/index.html b/src/spriteforge/studio/web/index.html new file mode 100644 index 0000000..f368415 --- /dev/null +++ b/src/spriteforge/studio/web/index.html @@ -0,0 +1,57 @@ + + + + +SpriteForge Studio + + +
SPRITEFORGE // STUDIOloading…
+
+

Create asset

+

Inpainting mask

Paint white where the generator may change the image. Right mouse button erases.

+

Approved animation preview

+
+ diff --git a/src/spriteforge/watch.py b/src/spriteforge/watch.py new file mode 100644 index 0000000..073b0d5 --- /dev/null +++ b/src/spriteforge/watch.py @@ -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)) diff --git a/tests/.gitkeep b/tests/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/c99_smoke.c b/tests/c99_smoke.c new file mode 100644 index 0000000..32b166a --- /dev/null +++ b/tests/c99_smoke.c @@ -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; +} diff --git a/tests/cpp17_smoke.cpp b/tests/cpp17_smoke.cpp new file mode 100644 index 0000000..512e693 --- /dev/null +++ b/tests/cpp17_smoke.cpp @@ -0,0 +1,8 @@ +#include "sfa.hpp" +#include +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; +} diff --git a/tests/make_fixture.py b/tests/make_fixture.py new file mode 100644 index 0000000..82989f2 --- /dev/null +++ b/tests/make_fixture.py @@ -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") diff --git a/tests/make_palette_fixture.py b/tests/make_palette_fixture.py new file mode 100644 index 0000000..1cfc8a4 --- /dev/null +++ b/tests/make_palette_fixture.py @@ -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))}) diff --git a/tests/sfp_c99_smoke.c b/tests/sfp_c99_smoke.c new file mode 100644 index 0000000..f98de72 --- /dev/null +++ b/tests/sfp_c99_smoke.c @@ -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; +} diff --git a/tests/test_backend_registry.py b/tests/test_backend_registry.py new file mode 100644 index 0000000..f385429 --- /dev/null +++ b/tests/test_backend_registry.py @@ -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" diff --git a/tests/test_blender_backend.py b/tests/test_blender_backend.py new file mode 100644 index 0000000..3ac5e49 --- /dev/null +++ b/tests/test_blender_backend.py @@ -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") diff --git a/tests/test_build_stage3.py b/tests/test_build_stage3.py new file mode 100644 index 0000000..977ead1 --- /dev/null +++ b/tests/test_build_stage3.py @@ -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() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..91dbbeb --- /dev/null +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_comfyui_provider.py b/tests/test_comfyui_provider.py new file mode 100644 index 0000000..733344c --- /dev/null +++ b/tests/test_comfyui_provider.py @@ -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)==[] diff --git a/tests/test_format.py b/tests/test_format.py new file mode 100644 index 0000000..cad4192 --- /dev/null +++ b/tests/test_format.py @@ -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(" 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),) diff --git a/tests/test_incremental.py b/tests/test_incremental.py new file mode 100644 index 0000000..b05e2fb --- /dev/null +++ b/tests/test_incremental.py @@ -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 diff --git a/tests/test_introspection.py b/tests/test_introspection.py new file mode 100644 index 0000000..308c4ca --- /dev/null +++ b/tests/test_introspection.py @@ -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 diff --git a/tests/test_manifest.py b/tests/test_manifest.py new file mode 100644 index 0000000..47ad192 --- /dev/null +++ b/tests/test_manifest.py @@ -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]) diff --git a/tests/test_manifest_version.py b/tests/test_manifest_version.py new file mode 100644 index 0000000..eefdc52 --- /dev/null +++ b/tests/test_manifest_version.py @@ -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) diff --git a/tests/test_pixel.py b/tests/test_pixel.py new file mode 100644 index 0000000..15a8f9f --- /dev/null +++ b/tests/test_pixel.py @@ -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"] diff --git a/tests/test_procedural.py b/tests/test_procedural.py new file mode 100644 index 0000000..734695c --- /dev/null +++ b/tests/test_procedural.py @@ -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 diff --git a/tests/test_relay.py b/tests/test_relay.py new file mode 100644 index 0000000..b7ee7e7 --- /dev/null +++ b/tests/test_relay.py @@ -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() 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) diff --git a/tests/test_stage8.py b/tests/test_stage8.py new file mode 100644 index 0000000..4c1d77a --- /dev/null +++ b/tests/test_stage8.py @@ -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) diff --git a/tests/test_studio_batch.py b/tests/test_studio_batch.py new file mode 100644 index 0000000..9a948d5 --- /dev/null +++ b/tests/test_studio_batch.py @@ -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 diff --git a/tests/test_studio_config.py b/tests/test_studio_config.py new file mode 100644 index 0000000..dc31249 --- /dev/null +++ b/tests/test_studio_config.py @@ -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" diff --git a/tests/test_studio_controls.py b/tests/test_studio_controls.py new file mode 100644 index 0000000..3caaa1d --- /dev/null +++ b/tests/test_studio_controls.py @@ -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"} diff --git a/tests/test_studio_export.py b/tests/test_studio_export.py new file mode 100644 index 0000000..5ed0fa8 --- /dev/null +++ b/tests/test_studio_export.py @@ -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() diff --git a/tests/test_studio_jobs.py b/tests/test_studio_jobs.py new file mode 100644 index 0000000..1483b2d --- /dev/null +++ b/tests/test_studio_jobs.py @@ -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() bytes: + image = Image.new("RGBA", (8, 6), color) + output = io.BytesIO() + image.save(output, "PNG") + return output.getvalue() + + +def test_project_asset_candidate_approval_roundtrip(tmp_path): + store = ProjectStore.create(tmp_path / "art", "horror_ops", "Horror Ops") + store.update_style(StyleBible(description="industrial biomechanical horror")) + store.add_asset(Asset(id="field_agent", name="Field Agent", kind="character", + target_width=60, target_height=60)) + reference = store.import_png(png(), "reference") + store.attach_reference("field_agent", reference) + _, shot = store.add_shot("field_agent", "idle", 0, 0) + candidate_media = store.import_png(png((90, 10, 10, 255)), "candidate") + recipe = GenerationRecipe(provider="test", model="fake", seed=7, prompt="agent", + width=256, height=256, reference_hashes=[reference.hash]) + _, candidate = store.add_candidate("field_agent", shot.id, candidate_media, recipe) + store.decide("field_agent", shot.id, candidate.id, "approved") + + project = store.load() + saved = project.assets[0].shots[0] + assert saved.approved_candidate_id == candidate.id + assert saved.candidates[0].decision == "approved" + assert store.media_path(saved.candidates[0].image.path).is_file() + + +def test_media_is_content_addressed_and_canonicalized(tmp_path): + store = ProjectStore.create(tmp_path / "art", "test_project", "Test") + first = store.import_png(png(), "reference") + second = store.import_png(png(), "candidate") + assert first.hash == second.hash + assert first.path == second.path + + +def test_store_rejects_escape_and_duplicate_asset(tmp_path): + store = ProjectStore.create(tmp_path / "art", "test_project", "Test") + asset = Asset(id="agent", name="Agent", kind="character", target_width=60, target_height=60) + store.add_asset(asset) + with pytest.raises(ValueError, match="already exists"): + store.add_asset(asset) + with pytest.raises(ValueError, match="escapes"): + store.media_path("../secret.png") diff --git a/tests/test_studio_validation.py b/tests/test_studio_validation.py new file mode 100644 index 0000000..67c0562 --- /dev/null +++ b/tests/test_studio_validation.py @@ -0,0 +1,11 @@ +from spriteforge.studio.models import Asset +from spriteforge.studio.store import ProjectStore +from spriteforge.studio.validation import validate_project,validation_text + + +def test_validation_reports_incomplete_grid_and_missing_approval(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,directions=8));store.add_shot("agent","idle",0,0) + issues=validate_project(store) + assert any("missing 7 shot" in x.message for x in issues) + assert any("no approved" in x.message for x in issues) + assert "validation:" in validation_text(store)