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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .claude/skills/pixellab-cli-scenes/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,49 @@ so it does not read as pasted from another game.
```
pixellab-cli object new <description> --directions --size --view --style --reference --name
pixellab-cli object list
pixellab-cli object show <object_id>
```

**Pro pricing.** A managed prop with its own id, in one direction or eight. Reach for it
when the prop has to be seen from several angles or referred to later; a prop that only
has to look right from one angle is `pixellab-cli sprite` for about one generation.

`object show` is free and prints, for each animation the prop has, the identifier that
adds directions to it.

## A prop that moves, one direction at a time

```
pixellab-cli object animate <object_id> --action/-a --direction/-d --into --frames
--name --enhance --drop-first-frame --again
```

**A generation per frame per direction.** The default is eight frames, so one direction
is about eight generations and all eight are about sixty-four. The command names the
directions and the total before it calls; read that line rather than the flags.

**This is the one animation you can come back to.** A character's animation cannot take
another direction — the provider refuses the field — but an object's can:

```bash
pixellab-cli --yes object animate <id> -a "the barrel rocks on its base and settles back"
pixellab-cli object show <id> # the animation_group_id is on the line
pixellab-cli --yes object animate <id> --into <animation_group_id>
```

The second call adds **only the directions that animation does not have yet**, inherits
its description, and needs no `--action`. Naming none is the usual way to run it: the
missing set is worked out from the object, and the cost of exactly those directions is
said before anything is sent. `--again` regenerates one it already holds, which is the
only way past the refusal.

Animate one direction first and look at it. A rocking barrel that reads wrong in south
reads wrong in eight directions, and finding that out costs eight generations instead of
sixty-four.

A one-direction object animates the direction it has; `--direction` on one is refused,
because the route answers `400` to it.

## Assembling a map is not a generation problem

This tool makes tiles and props. Painting them into a level, auto-tiling while you paint,
Expand Down
1 change: 1 addition & 0 deletions docs/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,4 @@ over this file answers with the example above as well as with the notes. -->
- n-0057 2026-09-20 #gotcha @src/pixellab_cli/run.py — approve gates on the estimate, not the route, so a reported zero passes and an absent estimate still asks
- n-0058 2026-09-20 #gotcha @src/pixellab_cli/pixellab.py — a path parameter is refused in _split_path when it holds a separator or a dot segment, so no route has to remember the check
- n-0059 2026-09-20 #gotcha @tests/test_skill.py — the skill tests walk a hand-written CATEGORY_SKILLS tuple, so a packaged skill missing from it is tested by nothing; TestNothingShipsUntested binds it to harness.packaged_skills
- n-0060 2026-09-20 #gotcha @src/pixellab_cli/catalog.py — frame_default lives in catalog.py and reads the route's own frame_count, because every animating command multiplies it by the directions to estimate a call
34 changes: 34 additions & 0 deletions specs/characters-and-animation/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,3 +375,37 @@ holds behind it, and the subject record folds the runs the same way, so an anima
built over two calls reaches `inspect` — and the atlas built from it — as one animation
over every direction it covers. The grouping the provider refuses is real everywhere a
game engine can see it.

## An object animation is the one the provider lets you extend

Everything the character route refuses, the object route offers. `POST
/v2/objects/{object_id}/animations` takes `animation_group_id` — *"pass the
animation_group_id of an existing animation on this object to add more directions to
it"* — returns it on the first call, inherits the description when extending, and
refuses a direction it already holds with `409` unless `replace_existing` says
otherwise. It even computes the missing set: with a group and no `directions`, the
server fills in the cardinals that are not there yet.

So `object animate` is the shape `character animate` could not be. `--into
<animation_group_id>` adds to an animation that exists; without it a new one starts and
its identifier is reported, because that identifier is what the next call extends.
`--again` is `replace_existing`, the same word the character side uses for the same
idea.

The CLI still reads the object first, and that read is free. It is what turns "omit the
directions and let the server decide" into a cost said out loud: `GET /v2/objects/{id}`
names every direction each animation already covers, so the directions this call will
actually generate are known before it is made, and so is the bill — one generation per
frame per direction, which for a default eight-frame animation over eight directions is
sixty-four. Nobody should meet that number on the invoice (R2.43). The same read is what
refuses a direction already animated before the provider's `409` costs a round trip
(R2.44).

`object show` exists for the same reason (R4.6): the identifier `--into` takes is issued
by the provider and reported nowhere else, so without it the extending flow has no way
to start. It lists the rotations, then each animation with its group identifier, its
description and the directions it holds.

Only `mode='v3'` is reachable. `pro` is twenty to forty generations *per direction* —
up to three hundred and twenty for eight — and nothing here needs its different look
enough to put that behind a flag that could be typed by accident.
10 changes: 8 additions & 2 deletions specs/characters-and-animation/requirements.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
---
autonomy: auto
ci: wait
branch: feat/one-animation-many-directions
branch: feat/an-object-animation-takes-more-directions
delivery: in-review
pr: 78
pr: 82
---

# Characters and animation — requirements
Expand Down Expand Up @@ -85,6 +85,11 @@ different endpoint family and no skeleton.
- **R2.39** (ADDED) If a direction asked for is already animated on that character for the motion being animated, then the character commands shall refuse the call, name the animation and the directions it already holds, and say that the provider starts a new animation rather than adding a direction to one, before spending anything, unless the caller asks for it to be generated again.
- **R2.40** (ADDED) Where a direction is animated for a motion the character already has, the character commands shall report that the frames join that motion in the workspace and not in the animation the provider holds.

- **R2.41** (ADDED) When asked to animate an object, the character commands shall animate it from an action description over the directions asked for.
- **R2.42** (ADDED) Where an animation of that object is named, the character commands shall add to that animation the directions it does not yet hold, rather than starting a second one.
- **R2.43** (ADDED) The character commands shall report, before an object is animated, which directions the call will cover and what they are estimated to cost.
- **R2.44** (ADDED) If a direction asked for is already animated in the animation named, then the character commands shall refuse the call before spending anything, unless the caller asks for it to be generated again.


## R3 · Rotating

Expand All @@ -97,6 +102,7 @@ different endpoint family and no skeleton.
- **R4.3** When asked for a spritesheet, the character commands shall download the sheet and its layout file and write both to the workspace.
- **R4.4** Where a command names an asset that does not exist, the character commands shall say so rather than reporting an empty result.
- **R4.5** (ADDED) When showing a character, the character commands shall present the animations it holds for one motion as a single animation over every direction they cover, and shall say how many the provider holds for it.
- **R4.6** (ADDED) The character commands shall show one object's rotations and animations, naming for each animation the identifier that adds directions to it.

## Out of scope

Expand Down
16 changes: 16 additions & 0 deletions specs/characters-and-animation/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,19 @@
- [x] 7.4 (Unit) Teach the skill that one call carries every direction — R2.40
_Depends 7.2_
_Reason the provider refuses animation_group_id for characters_

## 8 · Animating an object
- [x] 8.1 (Unit) Add the object animation route to the catalogue — R2.41
_Reason the object route is the one that lets an animation take more directions_
- [x] 8.2 (Unit) Show an object's rotations and animations with their group ids — R4.6
_Reason the object route is the one that lets an animation take more directions_
- [x] 8.3 (Unit) Animate an object, naming directions and cost — R2.41, R2.43
_Depends 8.1_
_Reason the object route is the one that lets an animation take more directions_
- [x] 8.4 (Unit) Add the directions an animation lacks — R2.42, R2.44
_Depends 8.3_
_Reason the object route lets an animation take more directions_
- [x] 8.5 (Unit) Teach the scenes skill to animate an object and extend it — R2.42
_Depends 8.4_
_Reason the object route lets an animation take more directions_

24 changes: 24 additions & 0 deletions specs/provider-core/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,27 @@ declared length where there is one and by what arrived where there is not (R3.7)
Nothing narrower than a size limit is attempted — PixelLab serves assets from more than
one host and a host list would fail closed on the day they add another, which for a
tool whose whole job is to collect what was paid for is the more expensive failure.

## Job identifiers inside a list of objects

`characters/animations` returns `background_job_ids`, a flat list, and `_poll_id` takes
the first. `objects/{object_id}/animations` returns `submissions`: one object per
direction, each carrying its own `background_job_id` and `status`. The rule is the same
— follow the first, and the rest stay in the raw payload where the record keeps them —
so `_poll_id` reads the identifier out of the first entry when the list holds objects
rather than strings (R4.10). A route's own shape is the provider's business; which of
the identifiers is followed is not, and it is one rule for both.

## The identifier a response chose is still part of a URL

`_split_path` refuses a path parameter that carries a separator or a dot segment,
because the request built from it carries the bearer token. The job identifier a route
hands back is interpolated into the poll path by the very next request, which carries
the same token — so it is the same problem with the value arriving from the other
direction, and `collect` already said so for the one a caller types.

One helper now guards both, and the poll loop calls it before every attempt: a
`background_job_id` of `../../v2/characters` is refused rather than followed. That
covers `submissions`, where the object route puts its identifiers, and the flat
`background_job_ids` a character animation returns, which had the same gap one layer
shallower.
7 changes: 4 additions & 3 deletions specs/provider-core/requirements.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
---
autonomy: auto
ci: wait
branch: feat/the-ui-panel-you-paid-for
branch: feat/an-object-animation-takes-more-directions
delivery: in-review
pr: 79
pr: 82
---

# Provider core — requirements
Expand Down Expand Up @@ -47,7 +47,7 @@ looks like have to be decided once.
- **R3.3** If a caller supplies a parameter the route does not accept, a value outside an enumerated parameter's set, or a size the route rejects, then the provider core shall fail before the request is sent, naming the parameter and the values or bounds that are allowed.
- **R3.4** The route table shall be derived from the vendored provider schemas under `reference/`, and a route whose shape contradicts those schemas shall be reported by the suite rather than at call time.
- **R3.5** (ADDED) Where a route requires an image to match one of its size parameters, if the image given does not match it, then the provider core shall fail before the request is sent, naming both sizes.
- **R3.6** (ADDED) If a value that becomes part of a route's address carries a path separator or a dot segment, then the provider core shall refuse the call before anything is sent, because the request that would carry it carries the account's credential.
- **R3.6** (MODIFIED) If a value that becomes part of a route's address carries a path separator or a dot segment, then the provider core shall refuse the call before anything is sent, whether that value came from the caller or from a provider's own response, because the request that would carry it carries the account's credential.
- **R3.7** (ADDED) If a download named by a provider's response is larger than the ceiling this tool holds for a generated asset, then the provider core shall refuse it rather than read it.

## R4 · Background jobs
Expand All @@ -61,6 +61,7 @@ looks like have to be decided once.
- **R4.8** (ADDED) If polling stops before a job resolves, then the provider core shall record that call as still running and shall record no cost for it, because a call that has not finished has not reported one.
- **R4.7** (MODIFIED) Where a response declares how many images it holds and fewer were found, the provider core shall take them from a field it has seen carry them, and shall leave the count alone otherwise.
- **R4.9** (ADDED) Where a completed job reports the address of the image it made rather than its bytes, the provider core shall fetch that address and return the image, because the call has been charged and its result is otherwise never collected.
- **R4.10** (ADDED) Where a route reports its job identifiers inside a list of objects, one per direction, the provider core shall poll the first of them.

## R5 · Results and cost

Expand Down
9 changes: 9 additions & 0 deletions specs/provider-core/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,12 @@
- [x] 7.3 (TDD) Refuse a download past the ceiling for a generated asset — R3.7
_Reason the address is now fetched unasked_


## 8 · Job identifiers inside a list of objects
- [x] 8.1 (Unit) Poll the first job id when the list holds objects — R4.10
_Reason the object animation route returns submissions_
- [x] 8.2 (Unit) Check each item of an enumerated list, not the list itself — R3.3
_Reason the first list parameter with choices refused every value_
- [x] 8.3 (Unit) Guard the job id a response chose, not only a typed one — R3.6
_Reason it reached the poll path unchecked_

43 changes: 43 additions & 0 deletions src/pixellab_cli/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -1166,6 +1166,35 @@ def _image(
)


ANIMATE_OBJECT = Route(
name="object-animations",
method="POST",
path="/objects/{object_id}/animations",
kind=RouteKind.BACKGROUND_JOB,
summary="Animate an object, or add directions to an animation it already has.",
estimated_generations=1.0,
result_id_field="submissions",
poll_path=BACKGROUND_JOBS_PATH,
asset_id_field="animation_group_id",
params=(
Param("object_id", ParamKind.STRING, required=True),
Param("animation_description", ParamKind.STRING, help="The motion, described."),
Param(
"animation_group_id",
ParamKind.STRING,
help="An animation of this object to add directions to.",
),
Param("directions", ParamKind.STRING_LIST, choices=DIRECTION),
Param("display_name", ParamKind.STRING),
Param("frame_count", ParamKind.INTEGER, minimum=4, maximum=16, default=8),
Param("mode", ParamKind.STRING, choices=("v3", "pro"), default="v3"),
Param("enhance_prompt", ParamKind.BOOLEAN, default=False),
Param("keep_first_frame", ParamKind.BOOLEAN, default=True),
Param("replace_existing", ParamKind.BOOLEAN, default=False),
),
path_params=("object_id",),
)

LIST_UI_ASSETS = Route(
name="ui-assets",
method="GET",
Expand Down Expand Up @@ -1230,13 +1259,27 @@ def _image(
CHARACTER_SPRITESHEET,
LIST_OBJECTS,
GET_OBJECT,
ANIMATE_OBJECT,
LIST_UI_ASSETS,
GET_UI_ASSET,
)

BY_NAME: dict[str, Route] = {route.name: route for route in ROUTES}


def frame_default(route: Route) -> int:
"""How many frames a route draws when nobody says.

Read off the route's own `frame_count` rather than repeated per command: every
animating command multiplies this by the number of directions to estimate what a
call will cost, and two copies of it drift the day one route's default changes.
"""
for param in route.params:
if param.name == "frame_count":
return int(param.default or 8)
return 8


def route(name: str) -> Route:
"""Look a route up by name, or say what the names are."""
try:
Expand Down
12 changes: 2 additions & 10 deletions src/pixellab_cli/commands/character.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,14 +319,6 @@ def check_pose_belongs(
)


def _frame_default(route) -> int:
"""How many frames the route draws when nobody says. The estimate depends on it."""
for param in route.params:
if param.name == "frame_count":
return int(param.default or 8)
return 8


def known_templates() -> set[str]:
catalogue = load_templates()
return {name for ids in catalogue.get("families", {}).values() for name in ids}
Expand Down Expand Up @@ -1046,7 +1038,7 @@ def _animate(
# template drives the skeleton instead, and stays on the route's tier.
per_direction = route.estimated_generations
if not template:
per_direction = float(frames or _frame_default(route))
per_direction = float(frames or catalog.frame_default(route))
estimate = Cost(generations=per_direction * len(wanted))
output.stderr(
f"{len(wanted)} direction(s), one job each — about "
Expand All @@ -1058,7 +1050,7 @@ def _animate(
# n-0023). Said before the call because the difference is otherwise discovered in
# the file names, and a frame count is usually chosen to fit a loop.
if not template:
generated = int(frames or _frame_default(route))
generated = int(frames or catalog.frame_default(route))
stored = generated if drop_first_frame else generated + 1
output.stderr(
f"{generated} frame(s) generated per direction, {stored} held"
Expand Down
Loading
Loading