spritegen-cli 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.gitignore +6 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/PKG-INFO +1 -1
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0003-append-only-jsonl-ledger.md +2 -1
- spritegen_cli-0.3.0/docs/adr/0015-record-the-call-before-the-files-it-writes.md +53 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/codewiki/spending-money.md +5 -5
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/codewiki/the-stage-registry.md +10 -5
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/codewiki/the-workspace.md +9 -9
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/notes.md +3 -0
- spritegen_cli-0.3.0/plans/code-health.md +119 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/pyproject.toml +1 -1
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/atlas.py +29 -11
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/cli.py +14 -4
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/clip.py +84 -32
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/drive.py +14 -5
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/fal.py +45 -8
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/imaging.py +24 -3
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/ledger.py +43 -4
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/matting.py +16 -2
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/migrate.py +1 -1
- spritegen_cli-0.3.0/src/spritegen/report.py +260 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/sheet.py +7 -4
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/skill/__init__.py +47 -14
- spritegen_cli-0.3.0/src/spritegen/stages/__init__.py +80 -0
- spritegen_cli-0.3.0/src/spritegen/stages/_common.py +105 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/stages/anchor.py +7 -19
- spritegen_cli-0.2.0/src/spritegen/stages/__init__.py → spritegen_cli-0.3.0/src/spritegen/stages/catalog.py +47 -155
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/stages/matte.py +17 -4
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/stages/motion.py +30 -19
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/stages/pose.py +10 -27
- spritegen_cli-0.3.0/src/spritegen/stages/registry.py +59 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/stages/video.py +16 -61
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/upscale.py +43 -16
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/workspace.py +91 -263
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/conftest.py +29 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_anchor.py +4 -12
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_board_stage.py +3 -10
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_cli.py +29 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_clip.py +54 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_fal.py +120 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_imaging.py +24 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_ledger.py +59 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_matte.py +29 -8
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_matting.py +36 -3
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_migrate.py +5 -3
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_motion.py +26 -11
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_pose.py +6 -8
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_sheet.py +24 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_show.py +3 -3
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_skill.py +24 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_stages.py +30 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_upscale.py +116 -3
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_versions.py +0 -7
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_video.py +3 -11
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_workspace.py +53 -23
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/uv.lock +1 -1
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/agents/code-review.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/agents/security-review.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/commands/scc-adr.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/commands/scc-codewiki.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/commands/scc-glossary.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/commands/scc-init.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/commands/scc-plan-run.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/commands/scc-prd.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/commands/scc-stack.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/commands/scc-wiki.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/artifacts.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/autonomy.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/caveman.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/code-search.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/delivery.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/knowledge-base.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/methodology.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/notes.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/prior-art.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/project.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/routing.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/specs.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/tasks.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/verification.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/scc-manifest.json +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/skills/adr/SKILL.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/skills/codewiki/SKILL.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/skills/glossary/SKILL.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/skills/init/SKILL.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/skills/plan-run/SKILL.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/skills/prd/SKILL.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/skills/stack/SKILL.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/skills/wiki/SKILL.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.env.template +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.gitattributes +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.github/workflows/ci.yml +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.github/workflows/release.yml +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.python-version +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/CLAUDE.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/README.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0001-asset-directory-and-no-path-arguments.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0002-transfer-movement-instead-of-generating-frames.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0004-allow-list-every-downloaded-host.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0005-a-directory-per-artifact-kind.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0006-centralise-configuration-and-never-cache-it.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0007-heavy-dependencies-are-optional-extras.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0008-local-backends-are-the-default.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0009-walk-the-redirect-chain-here.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0010-ci-on-three-operating-systems.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0011-keep-pixelfixer-out-of-the-distribution.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0012-publish-with-one-secret.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0013-require-python-3-13.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0014-every-run-is-a-version-and-the-state-names-the-chosen-one.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/glossary.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/stack.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/wiki/changelog.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/wiki/index.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/wiki/pages/configuration.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/wiki/pages/grid-and-palette-recovery.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/wiki/pages/local-instead-of-paid.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/wiki/pages/motion-transfer.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/wiki/pages/paid-calls-and-the-ledger.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/wiki/pages/the-asset-directory.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/wiki/pages/the-generated-skill.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/wiki/pages/the-pipeline.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/plans/motion-optimisation.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/specs/artifact-versions/design.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/specs/artifact-versions/requirements.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/specs/artifact-versions/tasks.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/__init__.py +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/endpoints.py +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/prompts.py +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/rrdb.py +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/settings.py +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/skill/files/SKILL.md +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/stages/board.py +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/helpers.py +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/expected/anchor_crop.pixelart.json +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/expected/anchor_crop.pixelart.png +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/expected/synthetic_chroma.cut.json +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/expected/synthetic_chroma.cut.png +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/expected/synthetic_matted.json +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/expected/synthetic_row.png +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/expected/synthetic_video_board.json +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/expected/synthetic_video_board.png +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/expected/walk_south_row.gif +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/expected/walk_south_row.png +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/input/anchor_crop.png +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/input/synthetic_board.png +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/input/synthetic_chroma.png +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/input/synthetic_matted.png +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/input/walk_south_board.png +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/manifest.json +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_atlas.py +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_drive.py +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_parity.py +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_prompts.py +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_settings.py +0 -0
- {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/tests_fal_doubles.py +0 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
status: accepted
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# The ledger records a call before the files it writes
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
`adr:0003-append-only-jsonl-ledger` puts one line per paid call in `ledger.jsonl`,
|
|
10
|
+
carrying the endpoint, the payload, the URLs returned and the sha256 of every file
|
|
11
|
+
written. A line can only carry those digests once the files exist, so the write had to
|
|
12
|
+
wait for them.
|
|
13
|
+
|
|
14
|
+
What sits between the endpoint answering and the files landing is a download, a decode
|
|
15
|
+
and a pack. Any of it can fail — a dropped transfer, a clip `av` will not open, a full
|
|
16
|
+
disk — and the money is already spent when it does. The ledger is the only record that
|
|
17
|
+
a call happened, and in exactly that case it recorded nothing at all: the failure it
|
|
18
|
+
exists to survive was the one it did not.
|
|
19
|
+
|
|
20
|
+
Appending is what makes the file safe to write from a run that dies, and it is also
|
|
21
|
+
what stops the line being completed later. A record cannot be both written early and
|
|
22
|
+
filled in afterwards.
|
|
23
|
+
|
|
24
|
+
## Decision
|
|
25
|
+
|
|
26
|
+
A paid call is **two lines joined by a `call` id**, not one.
|
|
27
|
+
|
|
28
|
+
The first is written the moment the endpoint answers, before anything is downloaded:
|
|
29
|
+
`kind: "call"`, with the endpoint, the redacted payload and the URLs. The second is
|
|
30
|
+
written when the files land: `kind: "files"`, with the sha256 of each. A stage whose
|
|
31
|
+
whole output is what the call returned, with nothing to download, still writes one
|
|
32
|
+
line.
|
|
33
|
+
|
|
34
|
+
`summarise` counts a call where `kind` is not `"files"`, so the two lines fold to one
|
|
35
|
+
call and one set of files.
|
|
36
|
+
|
|
37
|
+
## Consequences
|
|
38
|
+
|
|
39
|
+
A run that dies mid-download leaves a `call` line with no `files` line. That is the
|
|
40
|
+
point: the asset's ledger says the money went, and the absent second line says nothing
|
|
41
|
+
landed. `cost` reports the call.
|
|
42
|
+
|
|
43
|
+
A line written before this decision has no `kind`, and is read as a whole call on its
|
|
44
|
+
own — old ledgers keep totalling to what they always did, and the format stays
|
|
45
|
+
append-only, so nothing on disk is rewritten.
|
|
46
|
+
|
|
47
|
+
The cost is that a reader can no longer assume one line is one call, and that the two
|
|
48
|
+
lines of a call are adjacent only in practice — a concurrent run against one asset
|
|
49
|
+
would interleave them, and the `call` id is what makes that legible rather than the
|
|
50
|
+
line order.
|
|
51
|
+
|
|
52
|
+
This supersedes `adr:0003-append-only-jsonl-ledger` on the shape of a line only. Its
|
|
53
|
+
other decisions — append-only, upload URLs redacted, one file per asset — stand.
|
|
@@ -6,7 +6,7 @@ goes wrong rather than because of a way it works.
|
|
|
6
6
|
|
|
7
7
|
## A URL an endpoint returned is still a URL somebody could have chosen
|
|
8
8
|
|
|
9
|
-
[src/spritegen/fal.py:24-
|
|
9
|
+
[src/spritegen/fal.py:24-88]()
|
|
10
10
|
|
|
11
11
|
The allowed hosts are a fixed tuple plus whatever `SPRITEGEN_ALLOWED_HOSTS` adds. The
|
|
12
12
|
scheme must be https, and an authority carrying userinfo is refused outright — `https://
|
|
@@ -16,7 +16,7 @@ check.
|
|
|
16
16
|
|
|
17
17
|
## The redirect chain is walked here, not by the client
|
|
18
18
|
|
|
19
|
-
[src/spritegen/fal.py:
|
|
19
|
+
[src/spritegen/fal.py:153-218]()
|
|
20
20
|
|
|
21
21
|
This is the reason `httpx` is a dependency at all. A client following redirects itself
|
|
22
22
|
would apply the host check to the URL that was handed in and to nothing after it, so one
|
|
@@ -25,7 +25,7 @@ hop out of the allowed set is enough to fetch from anywhere. Every hop is checke
|
|
|
25
25
|
|
|
26
26
|
## One door, and two refusals before anything is spent
|
|
27
27
|
|
|
28
|
-
[src/spritegen/fal.py:
|
|
28
|
+
[src/spritegen/fal.py:220-241]()
|
|
29
29
|
|
|
30
30
|
`require_key` and the `dry_run` branch live in `call` rather than in each stage, so no
|
|
31
31
|
stage can forget one. `_subscribe` is the only function that touches `fal_client`, and it
|
|
@@ -34,7 +34,7 @@ without an HTTP client and what lets a test stand in for the endpoint without on
|
|
|
34
34
|
|
|
35
35
|
## The record redacts what would otherwise be a key
|
|
36
36
|
|
|
37
|
-
[src/spritegen/ledger.py:
|
|
37
|
+
[src/spritegen/ledger.py:30-65]()
|
|
38
38
|
|
|
39
39
|
`fal_client.upload_file` returns standing access to the uploaded file for anyone holding
|
|
40
40
|
the link. Writing those into the ledger verbatim would turn a per-asset record into a way
|
|
@@ -44,7 +44,7 @@ named explicitly.
|
|
|
44
44
|
|
|
45
45
|
## One line, appended, never rewritten
|
|
46
46
|
|
|
47
|
-
[src/spritegen/ledger.py:
|
|
47
|
+
[src/spritegen/ledger.py:81-141]()
|
|
48
48
|
|
|
49
49
|
JSONL and `append` rather than a document re-serialised whole, because a run that dies
|
|
50
50
|
halfway has still spent the money. A format that rewrites the file to add a line can lose
|
|
@@ -5,9 +5,14 @@ builds its sub-commands from it, `workspace` checks order against it, and `skill
|
|
|
5
5
|
generates the published tables out of it. Reading the file tells you what it holds;
|
|
6
6
|
this page is why it holds it there rather than in the stages.
|
|
7
7
|
|
|
8
|
+
It is three files. `registry.py` is the two dataclasses a stage is described with,
|
|
9
|
+
`catalog.py` is the table itself — four fifths of the whole by line count and none of
|
|
10
|
+
its behaviour — and `__init__.py` is the lookup, which is what a reader of the registry
|
|
11
|
+
came for.
|
|
12
|
+
|
|
8
13
|
## An option is declared by the registry, not by the stage that takes it
|
|
9
14
|
|
|
10
|
-
[src/spritegen/stages/
|
|
15
|
+
[src/spritegen/stages/registry.py:12-26]()
|
|
11
16
|
|
|
12
17
|
The obvious place for `--size` is inside `anchor`. It is here instead because `--help`
|
|
13
18
|
has to be complete, and building help from the stages would mean importing five modules
|
|
@@ -16,7 +21,7 @@ value off the parsed namespace and knows nothing about how it was parsed.
|
|
|
16
21
|
|
|
17
22
|
## `requires` is any-one-of, because the pipeline forks
|
|
18
23
|
|
|
19
|
-
[src/spritegen/stages/
|
|
24
|
+
[src/spritegen/stages/registry.py:29-54]()
|
|
20
25
|
|
|
21
26
|
`motion` and `video` produce the same artifact by different routes, and `matte` takes
|
|
22
27
|
whichever ran. A field meaning *all of these* would have no way to express that, and the
|
|
@@ -24,7 +29,7 @@ fork is the shape of the tool rather than an exception in it.
|
|
|
24
29
|
|
|
25
30
|
## Kind and variant, once the stage stopped being the artifact
|
|
26
31
|
|
|
27
|
-
[src/spritegen/stages/
|
|
32
|
+
[src/spritegen/stages/registry.py:29-59]()
|
|
28
33
|
|
|
29
34
|
`produces` was the stage's name for as long as a stage made one thing. `anchor` makes an
|
|
30
35
|
anchor, box art or an icon; `board` closes one set of frames into as many art directions
|
|
@@ -38,7 +43,7 @@ Both name an option this stage already declares, and neither is turned into a pa
|
|
|
38
43
|
|
|
39
44
|
## Order is derived, and there is no list of it
|
|
40
45
|
|
|
41
|
-
[src/spritegen/stages/__init__.py:
|
|
46
|
+
[src/spritegen/stages/__init__.py:43-63]()
|
|
42
47
|
|
|
43
48
|
`order` walks the table rather than reading a sequence somebody maintained beside it. A
|
|
44
49
|
second record of the pipeline's shape would be the one that goes stale the first time a
|
|
@@ -46,7 +51,7 @@ stage is added.
|
|
|
46
51
|
|
|
47
52
|
## The module is resolved only when the command has been chosen
|
|
48
53
|
|
|
49
|
-
[src/spritegen/stages/__init__.py:
|
|
54
|
+
[src/spritegen/stages/__init__.py:66-80]()
|
|
50
55
|
|
|
51
56
|
This is the reason the registry can describe the whole pipeline without importing Pillow,
|
|
52
57
|
numpy or `fal_client`. It also decides the blast radius of a broken stage: an import
|
|
@@ -6,7 +6,7 @@ refusing things.
|
|
|
6
6
|
|
|
7
7
|
## Every upward search stops, and the stop is the security control
|
|
8
8
|
|
|
9
|
-
[src/spritegen/workspace.py:
|
|
9
|
+
[src/spritegen/workspace.py:70-101]()
|
|
10
10
|
|
|
11
11
|
A command is typed from wherever is convenient, so searching upward for the project is
|
|
12
12
|
right. Searching upward without a ceiling reaches the filesystem root, and everything
|
|
@@ -16,7 +16,7 @@ directory nobody chose to configure this tool.
|
|
|
16
16
|
|
|
17
17
|
## Where the assets are, and why the order is the design
|
|
18
18
|
|
|
19
|
-
[src/spritegen/workspace.py:
|
|
19
|
+
[src/spritegen/workspace.py:103-138]()
|
|
20
20
|
|
|
21
21
|
The explicit variable wins first, which is what lets a test and a second collection of
|
|
22
22
|
characters exist at all. The nearest existing `spritegen/assets/` comes next because that
|
|
@@ -25,9 +25,9 @@ else's, so the images belong to that project rather than to the tool.
|
|
|
25
25
|
|
|
26
26
|
## A name off a command line becomes a directory
|
|
27
27
|
|
|
28
|
-
[src/spritegen/workspace.py:
|
|
28
|
+
[src/spritegen/workspace.py:49-68]()
|
|
29
29
|
|
|
30
|
-
[src/spritegen/workspace.py:
|
|
30
|
+
[src/spritegen/workspace.py:140-168]()
|
|
31
31
|
|
|
32
32
|
The forbidden set and the reserved names are Windows', and they are checked on every
|
|
33
33
|
platform. An asset name arrives from an argument or from a state file written earlier,
|
|
@@ -36,7 +36,7 @@ on Windows — which is why CI runs the suite on three operating systems rather
|
|
|
36
36
|
|
|
37
37
|
## Kind, variant and version become a path in exactly one place
|
|
38
38
|
|
|
39
|
-
[src/spritegen/workspace.py:
|
|
39
|
+
[src/spritegen/workspace.py:476-505]()
|
|
40
40
|
|
|
41
41
|
Every stage writes through this. That is what let layout 1 become layout 2 without each
|
|
42
42
|
stage learning about the move, layout 2 become layout 3 the same way, and it is what
|
|
@@ -44,9 +44,9 @@ stage learning about the move, layout 2 become layout 3 the same way, and it is
|
|
|
44
44
|
|
|
45
45
|
## Which version a run writes, and which one it reads
|
|
46
46
|
|
|
47
|
-
[src/spritegen/workspace.py:
|
|
47
|
+
[src/spritegen/workspace.py:527-541]()
|
|
48
48
|
|
|
49
|
-
[src/spritegen/workspace.py:
|
|
49
|
+
[src/spritegen/workspace.py:667-684]()
|
|
50
50
|
|
|
51
51
|
Two records go into picking a number and neither is trusted over the other: what the
|
|
52
52
|
state file recorded, and what is on disk. They disagree when a run died between writing
|
|
@@ -64,7 +64,7 @@ has none, so a re-run cannot re-aim the paid stages that follow it — that is
|
|
|
64
64
|
|
|
65
65
|
## A recorded path is not trusted
|
|
66
66
|
|
|
67
|
-
[src/spritegen/workspace.py:
|
|
67
|
+
[src/spritegen/workspace.py:547-570]()
|
|
68
68
|
|
|
69
69
|
The state file holds relative paths, and it is a file on disk that something else may
|
|
70
70
|
have written. `inside` is the check that a recorded path still resolves within the asset
|
|
@@ -73,7 +73,7 @@ another asset's output because the state said so.
|
|
|
73
73
|
|
|
74
74
|
## What "ready" means when `requires` forks
|
|
75
75
|
|
|
76
|
-
[src/spritegen/workspace.py:
|
|
76
|
+
[src/spritegen/workspace.py:414-454]()
|
|
77
77
|
|
|
78
78
|
`satisfied` is where the registry's any-one-of semantics turn into a decision, and
|
|
79
79
|
`next_stages` is what `status` prints. Both read the registry; neither keeps a copy of
|
|
@@ -41,3 +41,6 @@ over this file answers with the example above as well as with the notes. -->
|
|
|
41
41
|
- n-0001 2026-08-28 #gotcha @src/spritegen/workspace.py — Context.version is resolved on the first ask for out and kept, so a stage that opens its output twice in one run writes both halves to the same version
|
|
42
42
|
- n-0002 2026-08-28 #gotcha @tests/conftest.py — settings.DEFAULT_CACHE is built from Path.home() at import, so conftest moving home afterwards does not move the cache; the suite points SPRITEGEN_CACHE at a temporary directory instead
|
|
43
43
|
- n-0003 2026-08-28 #gotcha @pyproject.toml — the assets root spritegen/ in the repo root shadows the package name, so coverage source=[spritegen] resolves to the empty assets directory and collects nothing — source_pkgs is the unambiguous form
|
|
44
|
+
- n-0004 2026-08-28 #gotcha @src/spritegen/sheet.py — the GIF preview writes one global palette plus one shared local palette whatever the frames were quantised with — Pillow unifies on save, so quantising per frame and quantising the strip produce the same file
|
|
45
|
+
- n-0005 2026-08-28 #gotcha @src/spritegen/upscale.py — the weight cache sidecar must stay a content hash — size and mtime are forgeable with the same write access a swap needs, so a stat-based fast path silently disables the check
|
|
46
|
+
- n-0006 2026-08-28 #gotcha @docs/codewiki/the-workspace.md — a codewiki citation that still resolves after a refactor is not still correct — the range shifts onto a neighbouring function and validate cannot tell, so check what each range opens on
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
---
|
|
2
|
+
autonomy: auto
|
|
3
|
+
ci: wait
|
|
4
|
+
status: approved
|
|
5
|
+
pr: per-group
|
|
6
|
+
merge: auto
|
|
7
|
+
checksum: a381e0be7b8276b7d56f6be879477d27acc6bab7600bae8957b6bee7d8d0b577
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Code health
|
|
11
|
+
|
|
12
|
+
Clear what a full read of `src/` and `tests/` turned up: ten defects, seven places
|
|
13
|
+
that repeat work already done, four modules that carry more than one job, and a test
|
|
14
|
+
suite that pays for video encoding no assertion reads.
|
|
15
|
+
|
|
16
|
+
## Why
|
|
17
|
+
|
|
18
|
+
None of this was found by a failure — the suite is green and has been. It is what a
|
|
19
|
+
deliberate read found sitting under green tests, which is why it is worth landing as
|
|
20
|
+
one piece of work rather than waiting for each item to announce itself. Three of the
|
|
21
|
+
defects lose something a user cannot get back: a paid call whose ledger line is never
|
|
22
|
+
written, a re-fetch that deletes the good file it was going to replace, and a weight
|
|
23
|
+
file that stops being checked the moment its digest sidecar goes missing. The rest is
|
|
24
|
+
waste that compounds — a 64 MB model re-read per image, a colour count that allocates
|
|
25
|
+
one tuple per pixel of an upscaled frame, twenty-five tests re-encoding the same clip
|
|
26
|
+
at the slowest x264 preset. Done when the defects are fixed with tests that would have
|
|
27
|
+
caught them, the repeated work happens once, the four oversized modules are split
|
|
28
|
+
along the seams the survey mapped, and the suite is faster with no coverage lost.
|
|
29
|
+
|
|
30
|
+
## Paths
|
|
31
|
+
|
|
32
|
+
- `src/spritegen/` — `workspace.py`, `fal.py`, `upscale.py`, `imaging.py`, `clip.py`,
|
|
33
|
+
`drive.py`, `matting.py`, `sheet.py`, `cli.py`, `atlas.py`, `ledger.py`
|
|
34
|
+
- `src/spritegen/stages/` — `__init__.py`, `motion.py`, `pose.py`, `video.py`,
|
|
35
|
+
`anchor.py`, `matte.py`, `board.py`
|
|
36
|
+
- `src/spritegen/skill/__init__.py`
|
|
37
|
+
- `tests/` — `conftest.py`, `helpers.py`, `test_motion.py`, `test_video.py`
|
|
38
|
+
|
|
39
|
+
## References
|
|
40
|
+
|
|
41
|
+
- `adr:0003-append-only-jsonl-ledger` — one line per paid call, carrying the sha256 of
|
|
42
|
+
every file written. Task 1.1 cannot hold to both halves at once and supersedes it.
|
|
43
|
+
- `adr:0006-centralise-configuration-and-never-cache-it` — nothing is cached and
|
|
44
|
+
`load()` builds a new object every call. Task 3.5 resolves once per command and
|
|
45
|
+
passes it down, which is the shape that ADR permits; a module-level cache is not.
|
|
46
|
+
- `adr:0004-allow-list-every-downloaded-host` — the host check task 1.9 walks results
|
|
47
|
+
for.
|
|
48
|
+
- `adr:0007-heavy-dependencies-are-optional-extras` — why `available()` exists at all,
|
|
49
|
+
which task 4.7 decides the fate of.
|
|
50
|
+
|
|
51
|
+
## Out of scope
|
|
52
|
+
|
|
53
|
+
- The unbounded recursion in `fal.seed_in` and `fal.urls_in`: a hostile response nested
|
|
54
|
+
a thousand levels deep raises `RecursionError` after billing. Hosts are allow-listed
|
|
55
|
+
and the impact is one denied run; it is filed on PR #17 and belongs with a review of
|
|
56
|
+
what else trusts endpoint JSON shape.
|
|
57
|
+
- Introducing `ruff format`. No formatter is configured here and adding one rewrites
|
|
58
|
+
the tree in a commit nobody can read.
|
|
59
|
+
- Raising the coverage floor. The real number is 91.36% against a floor of 86, but it
|
|
60
|
+
has been there for one day.
|
|
61
|
+
|
|
62
|
+
## Tasks
|
|
63
|
+
|
|
64
|
+
- [x] 1.1 (TDD) Record the paid call in the ledger before the files it writes, and supersede `adr:0003` with the two-line shape
|
|
65
|
+
- [x] 1.2 (TDD) Download to a partial file and rename on success, so a failed re-fetch cannot delete the good file
|
|
66
|
+
_Depends 1.1_
|
|
67
|
+
- [x] 1.3 (TDD) Write the weight digest when the sidecar is missing, and stop the `finally` arm deleting a completed download
|
|
68
|
+
- [x] 1.4 (Unit) Default `--device` to `None` so the setting is reachable and a dry run names the device the real run will use
|
|
69
|
+
- [x] 1.5 (Unit) Make `matte --mask` on the local backend either write the mask or say it cannot
|
|
70
|
+
- [ ] 1.6 (Unit) Quantise the GIF preview once over the strip instead of a palette per
|
|
71
|
+
frame
|
|
72
|
+
_Status removed_
|
|
73
|
+
_Reason measured against the parity fixture: Pillow already writes one global palette plus one shared local palette whatever the frames were quantised with, so the per-frame ADAPTIVE call produces the same two palettes the strip does — the change grew the file by 800 bytes, altered no palette, and broke a documented parity baseline_
|
|
74
|
+
- [x] 1.7 (Unit) Map transport, codec and OS errors to exit codes, and narrow the catch that turns an internal bug into a user message
|
|
75
|
+
- [x] 1.8 (Unit) Name the file when `state.json` does not parse
|
|
76
|
+
- [x] 1.9 (Unit) Keep walking under a `url` key whose value is not a string
|
|
77
|
+
- [x] 1.10 (Unit) Generate the skill's free-command table from the parser instead of maintaining a second copy
|
|
78
|
+
- [x] 2.1 (Unit) Build the upscale network once per command and pass it to each file
|
|
79
|
+
- [x] 2.2 (Unit) Count colours before the upscale and without a tuple per pixel
|
|
80
|
+
- [x] 2.3 (TDD) Choose the wanted frame indices before decoding, and keep only those
|
|
81
|
+
- [ ] 2.4 (Unit) Re-hash a cached weight only when its size or mtime changed
|
|
82
|
+
_Status removed_
|
|
83
|
+
_Reason the win it chased was already delivered by 2.1 — with the network built once per command the weight is hashed once per command either way — and the mechanism defeated the integrity check beside it: size and mtime are forgeable with the same write access the attack needs, so a matching stamp let a swapped weight through unhashed and silent_
|
|
84
|
+
- [x] 2.5 (Unit) Resolve settings once per command and pass them down, adding no cache
|
|
85
|
+
_Depends 1.4_
|
|
86
|
+
- [x] 2.6 (Unit) Measure alpha in memory, read the sheet once per build, and skip the crops a run without a GIF throws away
|
|
87
|
+
- [x] 2.7 (Unit) Compare found against recorded with sets, not lists
|
|
88
|
+
- [x] 3.1 (Unit) Move the stage catalogue out of `stages/__init__.py` into its own module
|
|
89
|
+
- [ ] 3.2 (Unit) Declare the chroma, prompt, reference and frame-extraction options once
|
|
90
|
+
and splice them into each stage
|
|
91
|
+
_Depends 3.1_
|
|
92
|
+
_Status removed_
|
|
93
|
+
_Reason only the chroma and despill cluster was duplicated verbatim, and that is done. The prompt, reference and frame-extraction options share flags, kinds and defaults but not their help text: motion explains that the driving cycle caps --frames and video explains that an image-to-video clip opens on a still input image, which is the part of an option worth having. Replaced by 3.8_
|
|
94
|
+
- [x] 3.3 (Unit) Give `Context` the anchor lookup the three stages copy, and drop the cross-stage import
|
|
95
|
+
- [x] 3.4 (Unit) Extract the reference-file check, the missing-URL guard and the chroma finish that each exist three or four times
|
|
96
|
+
_Depends 3.3_
|
|
97
|
+
- [x] 3.5 (Unit) Extract the layout refusal and the artifact-key composition to one place each
|
|
98
|
+
- [x] 3.6 (Unit) Have the two grid packers share their arithmetic
|
|
99
|
+
- [x] 3.7 (Unit) Split `workspace.py` along the seams the survey mapped, presentation first
|
|
100
|
+
_Depends 3.3, 3.5_
|
|
101
|
+
- [x] 4.1 (Unit) Delete what nothing calls, and decide whether the availability helpers get a caller or go
|
|
102
|
+
- [x] 5.1 (Unit) Stop the motion tests encoding at 720 and the slowest preset
|
|
103
|
+
- [ ] 5.2 (Unit) Share the cache across the session so the same driving clip encodes
|
|
104
|
+
once
|
|
105
|
+
_Depends 5.1_
|
|
106
|
+
_Status removed_
|
|
107
|
+
_Reason measured after 5.1: an encode is 20ms at the cell size the tests now use, so sharing the cache across the session saves about 1s of a 20.8s suite, bought with mutable state shared across 799 tests against the one fixture whose purpose is isolating them_
|
|
108
|
+
- [x] 5.3 (Unit) Move the fixtures copied across eleven files into `conftest.py`
|
|
109
|
+
_Depends 5.1_
|
|
110
|
+
- [x] 3.8 (Unit) Declare the chroma and despill options once and splice them into the
|
|
111
|
+
three stages that cut
|
|
112
|
+
_Reason 3.2 asked for four option groups and only one of them was duplication; this is what was actually built_
|
|
113
|
+
|
|
114
|
+
## Done when
|
|
115
|
+
|
|
116
|
+
- `uv run pytest -q --cov` passes with coverage no lower than 91.36%.
|
|
117
|
+
- `uv run ruff check src tests` and `scc validate` both exit 0.
|
|
118
|
+
- Every task above is ticked or struck out with a reason.
|
|
119
|
+
- The suite's wall-clock time is lower than the 34 s it takes now, measured the same way.
|
|
@@ -35,6 +35,31 @@ def grid_for(count: int) -> tuple[int, int]:
|
|
|
35
35
|
return cols, rows
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
def lay_out(images: list, cols: int) -> tuple:
|
|
39
|
+
"""Paste images into a grid `cols` wide, and say where each one landed.
|
|
40
|
+
|
|
41
|
+
The cell is the largest image in the set, so every cell is the same size and the
|
|
42
|
+
grid can be read back by arithmetic alone — which is what both callers need and
|
|
43
|
+
what each of them used to compute for itself.
|
|
44
|
+
"""
|
|
45
|
+
from PIL import Image
|
|
46
|
+
|
|
47
|
+
if not images:
|
|
48
|
+
raise ValueError("nothing to pack")
|
|
49
|
+
|
|
50
|
+
cell_w = max(image.width for image in images)
|
|
51
|
+
cell_h = max(image.height for image in images)
|
|
52
|
+
rows = math.ceil(len(images) / cols)
|
|
53
|
+
|
|
54
|
+
canvas = Image.new("RGBA", (cell_w * cols, cell_h * rows), (0, 0, 0, 0))
|
|
55
|
+
boxes = []
|
|
56
|
+
for index, image in enumerate(images):
|
|
57
|
+
left, top = (index % cols) * cell_w, (index // cols) * cell_h
|
|
58
|
+
canvas.paste(image.convert("RGBA"), (left, top))
|
|
59
|
+
boxes.append([left, top, left + image.width, top + image.height])
|
|
60
|
+
return canvas, boxes, (cell_w, cell_h), rows
|
|
61
|
+
|
|
62
|
+
|
|
38
63
|
def pack(paths: list[Path], out: Path) -> dict:
|
|
39
64
|
"""Write one atlas holding every image, and return where each of them went."""
|
|
40
65
|
from PIL import Image
|
|
@@ -43,18 +68,11 @@ def pack(paths: list[Path], out: Path) -> dict:
|
|
|
43
68
|
raise ValueError("nothing to pack")
|
|
44
69
|
|
|
45
70
|
images = [Image.open(path).convert("RGBA") for path in paths]
|
|
46
|
-
cell_w = max(image.width for image in images)
|
|
47
|
-
cell_h = max(image.height for image in images)
|
|
48
71
|
cols, rows = grid_for(len(images))
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
left, top = (index % cols) * cell_w, (index // cols) * cell_h
|
|
54
|
-
atlas.paste(image, (left, top))
|
|
55
|
-
placed.append(
|
|
56
|
-
{"name": path.name, "box": [left, top, left + image.width, top + image.height]}
|
|
57
|
-
)
|
|
72
|
+
atlas, boxes, (cell_w, cell_h), rows = lay_out(images, cols)
|
|
73
|
+
placed = [
|
|
74
|
+
{"name": path.name, "box": box} for path, box in zip(paths, boxes, strict=True)
|
|
75
|
+
]
|
|
58
76
|
|
|
59
77
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
60
78
|
atlas.save(out)
|
|
@@ -18,6 +18,7 @@ having to push it into the process first.
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
20
|
import argparse
|
|
21
|
+
import os
|
|
21
22
|
import sys
|
|
22
23
|
from collections.abc import Sequence
|
|
23
24
|
|
|
@@ -61,7 +62,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
61
62
|
"--model", choices=("anime", "general"), help="which weights; default from settings"
|
|
62
63
|
)
|
|
63
64
|
upscale.add_argument(
|
|
64
|
-
"--device", choices=("auto", "cuda", "cpu"),
|
|
65
|
+
"--device", choices=("auto", "cuda", "cpu"), help="where it runs; default from settings"
|
|
65
66
|
)
|
|
66
67
|
upscale.add_argument("--dry-run", action="store_true", help="print the files and touch none")
|
|
67
68
|
|
|
@@ -148,11 +149,11 @@ def _free_commands() -> dict:
|
|
|
148
149
|
the skill it writes. All of those modules are cheap to import, which is what makes a
|
|
149
150
|
free command answer without loading Pillow, numpy or fal_client.
|
|
150
151
|
"""
|
|
151
|
-
from . import migrate, skill, upscale
|
|
152
|
+
from . import migrate, report, skill, upscale
|
|
152
153
|
|
|
153
154
|
handlers = {}
|
|
154
155
|
for module, commands in (
|
|
155
|
-
(
|
|
156
|
+
(report, ("new", "status", "show", "cost", "choose")),
|
|
156
157
|
(skill, ("init",)),
|
|
157
158
|
(migrate, ("migrate",)),
|
|
158
159
|
(upscale, ("upscale",)),
|
|
@@ -175,7 +176,16 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
175
176
|
except NotImplementedError as exc:
|
|
176
177
|
print(f"spritegen: {exc}", file=sys.stderr)
|
|
177
178
|
return 3
|
|
178
|
-
except (KeyError,
|
|
179
|
+
except (KeyError, OSError, ValueError) as exc:
|
|
180
|
+
# OSError covers the file errors this used to name one at a time, plus the ones
|
|
181
|
+
# it did not: a read-only asset directory, a locked state file, a full disk.
|
|
182
|
+
#
|
|
183
|
+
# KeyError and ValueError are how this project refuses — StageRefused, Untrusted,
|
|
184
|
+
# Transport and an unknown model are all one of the two — but they are also what
|
|
185
|
+
# an ordinary bug raises, and a bug printed as one tidy line is a bug nobody can
|
|
186
|
+
# report. SPRITEGEN_DEBUG asks for the traceback instead.
|
|
187
|
+
if os.getenv("SPRITEGEN_DEBUG"):
|
|
188
|
+
raise
|
|
179
189
|
print(f"spritegen: {exc}", file=sys.stderr)
|
|
180
190
|
return 1
|
|
181
191
|
|
|
@@ -13,19 +13,88 @@ from __future__ import annotations
|
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
def decode_frames(video: Path) -> list:
|
|
17
|
-
"""Every frame of the clip, in order.
|
|
16
|
+
def decode_frames(video: Path, keep: list[int] | None = None) -> list:
|
|
17
|
+
"""Every frame of the clip, in order.
|
|
18
|
+
|
|
19
|
+
A file the codec will not open is reported as this file being undecodable, not as an
|
|
20
|
+
`av` traceback: the clip was paid for and downloaded, and what the user needs to know
|
|
21
|
+
is which file to look at.
|
|
22
|
+
|
|
23
|
+
`keep` names the frames worth building an image for, in clip order. The rest are
|
|
24
|
+
decoded — a codec cannot skip to frame N without them — and dropped rather than held.
|
|
25
|
+
"""
|
|
18
26
|
import av
|
|
19
27
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
28
|
+
wanted = None if keep is None else set(keep)
|
|
29
|
+
try:
|
|
30
|
+
with av.open(str(video)) as container:
|
|
31
|
+
stream = container.streams.video[0]
|
|
32
|
+
stream.thread_type = "AUTO"
|
|
33
|
+
if wanted is None:
|
|
34
|
+
images = [frame.to_image() for frame in container.decode(stream)]
|
|
35
|
+
else:
|
|
36
|
+
# Kept by index and then read back in the order asked for, because
|
|
37
|
+
# `--pick 3,3` is a legitimate request for one frame twice.
|
|
38
|
+
held = {
|
|
39
|
+
index: frame.to_image()
|
|
40
|
+
for index, frame in enumerate(container.decode(stream))
|
|
41
|
+
if index in wanted
|
|
42
|
+
}
|
|
43
|
+
images = [held[index] for index in keep if index in held]
|
|
44
|
+
except (av.FFmpegError, IndexError) as exc:
|
|
45
|
+
raise ValueError(f"{video} could not be decoded: {exc}") from exc
|
|
24
46
|
if not images:
|
|
25
47
|
raise ValueError(f"{video} has no frames in it")
|
|
26
48
|
return images
|
|
27
49
|
|
|
28
50
|
|
|
51
|
+
def frame_count(video: Path) -> int:
|
|
52
|
+
"""How many frames the clip holds, without building an image for any of them.
|
|
53
|
+
|
|
54
|
+
The container usually knows, and where it does not the frames are decoded and thrown
|
|
55
|
+
away — still far cheaper than `to_image` on every one, which is what the caller is
|
|
56
|
+
avoiding by asking.
|
|
57
|
+
"""
|
|
58
|
+
import av
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
with av.open(str(video)) as container:
|
|
62
|
+
stream = container.streams.video[0]
|
|
63
|
+
if stream.frames:
|
|
64
|
+
return int(stream.frames)
|
|
65
|
+
stream.thread_type = "AUTO"
|
|
66
|
+
return sum(1 for _ in container.decode(stream))
|
|
67
|
+
except (av.FFmpegError, IndexError) as exc:
|
|
68
|
+
raise ValueError(f"{video} could not be decoded: {exc}") from exc
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def frame_indices(
|
|
72
|
+
*, total: int, count: int, start: float, end: float, pick: list[int] | None
|
|
73
|
+
) -> list[int]:
|
|
74
|
+
"""Which frames of a clip of `total` answer the board — the choice, without the images.
|
|
75
|
+
|
|
76
|
+
Separate from `choose_frames` so the decode can keep only what this returns: a 5
|
|
77
|
+
second clip at 30fps is 150 frames of which six are used, and holding all of them as
|
|
78
|
+
images to throw 144 away is hundreds of megabytes.
|
|
79
|
+
"""
|
|
80
|
+
if pick:
|
|
81
|
+
bad = [index for index in pick if not 0 <= index < total]
|
|
82
|
+
if bad:
|
|
83
|
+
raise ValueError(f"frame indices outside the clip (0..{total - 1}): {bad}")
|
|
84
|
+
return list(pick)
|
|
85
|
+
|
|
86
|
+
low, high = round(start * (total - 1)), round(end * (total - 1))
|
|
87
|
+
if high - low < 1:
|
|
88
|
+
raise ValueError(f"start and end left fewer than 2 frames of {total}")
|
|
89
|
+
window = list(range(low, high + 1))
|
|
90
|
+
if count >= len(window):
|
|
91
|
+
return window
|
|
92
|
+
# The window's last frame repeats its first when the cycle closes, so it is left
|
|
93
|
+
# out: taking both would put a standing step at the seam.
|
|
94
|
+
span = len(window) - 1
|
|
95
|
+
return [window[round(index * span / count)] for index in range(count)]
|
|
96
|
+
|
|
97
|
+
|
|
29
98
|
def parse_pick(text: str | None) -> list[int] | None:
|
|
30
99
|
"""`12,17,22` to `[12, 17, 22]`."""
|
|
31
100
|
if not text:
|
|
@@ -46,23 +115,7 @@ def choose_frames(
|
|
|
46
115
|
after it — so extracting from zero spends one of six frames on a pose that does not
|
|
47
116
|
belong to the cycle. `start` cuts that entry off.
|
|
48
117
|
"""
|
|
49
|
-
|
|
50
|
-
if pick:
|
|
51
|
-
bad = [index for index in pick if not 0 <= index < total]
|
|
52
|
-
if bad:
|
|
53
|
-
raise ValueError(f"frame indices outside the clip (0..{total - 1}): {bad}")
|
|
54
|
-
return [images[index] for index in pick], list(pick)
|
|
55
|
-
|
|
56
|
-
low, high = round(start * (total - 1)), round(end * (total - 1))
|
|
57
|
-
if high - low < 1:
|
|
58
|
-
raise ValueError(f"start and end left fewer than 2 frames of {total}")
|
|
59
|
-
window = list(range(low, high + 1))
|
|
60
|
-
if count >= len(window):
|
|
61
|
-
return [images[index] for index in window], window
|
|
62
|
-
# The window's last frame repeats its first when the cycle closes, so it is left
|
|
63
|
-
# out: taking both would put a standing step at the seam.
|
|
64
|
-
span = len(window) - 1
|
|
65
|
-
indices = [window[round(index * span / count)] for index in range(count)]
|
|
118
|
+
indices = frame_indices(total=len(images), count=count, start=start, end=end, pick=pick)
|
|
66
119
|
return [images[index] for index in indices], indices
|
|
67
120
|
|
|
68
121
|
|
|
@@ -112,15 +165,14 @@ def phase_indices(
|
|
|
112
165
|
|
|
113
166
|
|
|
114
167
|
def pack_board(frames: list, cols: int, out: Path) -> tuple[int, int]:
|
|
115
|
-
"""Lay the chosen frames out in a grid, so `sheet` can read one grid over them all.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
board.paste(frame.convert("RGBA"), ((index % cols) * width, (index // cols) * height))
|
|
168
|
+
"""Lay the chosen frames out in a grid, so `sheet` can read one grid over them all.
|
|
169
|
+
|
|
170
|
+
The grid arithmetic is `atlas.lay_out`: a board and an atlas are the same uniform
|
|
171
|
+
grid, and the two had it written out separately.
|
|
172
|
+
"""
|
|
173
|
+
from . import atlas
|
|
174
|
+
|
|
175
|
+
board, _, _, rows = atlas.lay_out(frames, cols)
|
|
124
176
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
125
177
|
board.save(out)
|
|
126
178
|
return cols, rows
|