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.
Files changed (154) hide show
  1. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.gitignore +6 -0
  2. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/PKG-INFO +1 -1
  3. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0003-append-only-jsonl-ledger.md +2 -1
  4. spritegen_cli-0.3.0/docs/adr/0015-record-the-call-before-the-files-it-writes.md +53 -0
  5. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/codewiki/spending-money.md +5 -5
  6. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/codewiki/the-stage-registry.md +10 -5
  7. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/codewiki/the-workspace.md +9 -9
  8. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/notes.md +3 -0
  9. spritegen_cli-0.3.0/plans/code-health.md +119 -0
  10. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/pyproject.toml +1 -1
  11. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/atlas.py +29 -11
  12. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/cli.py +14 -4
  13. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/clip.py +84 -32
  14. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/drive.py +14 -5
  15. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/fal.py +45 -8
  16. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/imaging.py +24 -3
  17. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/ledger.py +43 -4
  18. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/matting.py +16 -2
  19. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/migrate.py +1 -1
  20. spritegen_cli-0.3.0/src/spritegen/report.py +260 -0
  21. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/sheet.py +7 -4
  22. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/skill/__init__.py +47 -14
  23. spritegen_cli-0.3.0/src/spritegen/stages/__init__.py +80 -0
  24. spritegen_cli-0.3.0/src/spritegen/stages/_common.py +105 -0
  25. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/stages/anchor.py +7 -19
  26. spritegen_cli-0.2.0/src/spritegen/stages/__init__.py → spritegen_cli-0.3.0/src/spritegen/stages/catalog.py +47 -155
  27. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/stages/matte.py +17 -4
  28. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/stages/motion.py +30 -19
  29. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/stages/pose.py +10 -27
  30. spritegen_cli-0.3.0/src/spritegen/stages/registry.py +59 -0
  31. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/stages/video.py +16 -61
  32. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/upscale.py +43 -16
  33. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/workspace.py +91 -263
  34. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/conftest.py +29 -0
  35. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_anchor.py +4 -12
  36. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_board_stage.py +3 -10
  37. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_cli.py +29 -0
  38. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_clip.py +54 -0
  39. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_fal.py +120 -0
  40. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_imaging.py +24 -0
  41. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_ledger.py +59 -0
  42. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_matte.py +29 -8
  43. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_matting.py +36 -3
  44. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_migrate.py +5 -3
  45. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_motion.py +26 -11
  46. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_pose.py +6 -8
  47. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_sheet.py +24 -0
  48. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_show.py +3 -3
  49. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_skill.py +24 -0
  50. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_stages.py +30 -0
  51. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_upscale.py +116 -3
  52. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_versions.py +0 -7
  53. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_video.py +3 -11
  54. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_workspace.py +53 -23
  55. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/uv.lock +1 -1
  56. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/agents/code-review.md +0 -0
  57. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/agents/security-review.md +0 -0
  58. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/commands/scc-adr.md +0 -0
  59. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/commands/scc-codewiki.md +0 -0
  60. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/commands/scc-glossary.md +0 -0
  61. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/commands/scc-init.md +0 -0
  62. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/commands/scc-plan-run.md +0 -0
  63. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/commands/scc-prd.md +0 -0
  64. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/commands/scc-stack.md +0 -0
  65. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/commands/scc-wiki.md +0 -0
  66. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/artifacts.md +0 -0
  67. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/autonomy.md +0 -0
  68. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/caveman.md +0 -0
  69. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/code-search.md +0 -0
  70. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/delivery.md +0 -0
  71. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/knowledge-base.md +0 -0
  72. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/methodology.md +0 -0
  73. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/notes.md +0 -0
  74. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/prior-art.md +0 -0
  75. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/project.md +0 -0
  76. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/routing.md +0 -0
  77. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/specs.md +0 -0
  78. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/tasks.md +0 -0
  79. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/rules/verification.md +0 -0
  80. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/scc-manifest.json +0 -0
  81. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/skills/adr/SKILL.md +0 -0
  82. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/skills/codewiki/SKILL.md +0 -0
  83. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/skills/glossary/SKILL.md +0 -0
  84. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/skills/init/SKILL.md +0 -0
  85. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/skills/plan-run/SKILL.md +0 -0
  86. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/skills/prd/SKILL.md +0 -0
  87. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/skills/stack/SKILL.md +0 -0
  88. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.claude/skills/wiki/SKILL.md +0 -0
  89. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.env.template +0 -0
  90. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.gitattributes +0 -0
  91. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.github/workflows/ci.yml +0 -0
  92. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.github/workflows/release.yml +0 -0
  93. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/.python-version +0 -0
  94. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/CLAUDE.md +0 -0
  95. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/README.md +0 -0
  96. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0001-asset-directory-and-no-path-arguments.md +0 -0
  97. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0002-transfer-movement-instead-of-generating-frames.md +0 -0
  98. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0004-allow-list-every-downloaded-host.md +0 -0
  99. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0005-a-directory-per-artifact-kind.md +0 -0
  100. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0006-centralise-configuration-and-never-cache-it.md +0 -0
  101. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0007-heavy-dependencies-are-optional-extras.md +0 -0
  102. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0008-local-backends-are-the-default.md +0 -0
  103. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0009-walk-the-redirect-chain-here.md +0 -0
  104. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0010-ci-on-three-operating-systems.md +0 -0
  105. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0011-keep-pixelfixer-out-of-the-distribution.md +0 -0
  106. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0012-publish-with-one-secret.md +0 -0
  107. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/adr/0013-require-python-3-13.md +0 -0
  108. {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
  109. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/glossary.md +0 -0
  110. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/stack.md +0 -0
  111. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/wiki/changelog.md +0 -0
  112. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/wiki/index.md +0 -0
  113. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/wiki/pages/configuration.md +0 -0
  114. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/wiki/pages/grid-and-palette-recovery.md +0 -0
  115. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/wiki/pages/local-instead-of-paid.md +0 -0
  116. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/wiki/pages/motion-transfer.md +0 -0
  117. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/wiki/pages/paid-calls-and-the-ledger.md +0 -0
  118. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/wiki/pages/the-asset-directory.md +0 -0
  119. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/wiki/pages/the-generated-skill.md +0 -0
  120. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/docs/wiki/pages/the-pipeline.md +0 -0
  121. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/plans/motion-optimisation.md +0 -0
  122. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/specs/artifact-versions/design.md +0 -0
  123. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/specs/artifact-versions/requirements.md +0 -0
  124. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/specs/artifact-versions/tasks.md +0 -0
  125. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/__init__.py +0 -0
  126. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/endpoints.py +0 -0
  127. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/prompts.py +0 -0
  128. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/rrdb.py +0 -0
  129. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/settings.py +0 -0
  130. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/skill/files/SKILL.md +0 -0
  131. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/src/spritegen/stages/board.py +0 -0
  132. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/helpers.py +0 -0
  133. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/expected/anchor_crop.pixelart.json +0 -0
  134. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/expected/anchor_crop.pixelart.png +0 -0
  135. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/expected/synthetic_chroma.cut.json +0 -0
  136. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/expected/synthetic_chroma.cut.png +0 -0
  137. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/expected/synthetic_matted.json +0 -0
  138. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/expected/synthetic_row.png +0 -0
  139. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/expected/synthetic_video_board.json +0 -0
  140. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/expected/synthetic_video_board.png +0 -0
  141. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/expected/walk_south_row.gif +0 -0
  142. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/expected/walk_south_row.png +0 -0
  143. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/input/anchor_crop.png +0 -0
  144. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/input/synthetic_board.png +0 -0
  145. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/input/synthetic_chroma.png +0 -0
  146. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/input/synthetic_matted.png +0 -0
  147. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/input/walk_south_board.png +0 -0
  148. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/parity/manifest.json +0 -0
  149. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_atlas.py +0 -0
  150. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_drive.py +0 -0
  151. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_parity.py +0 -0
  152. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_prompts.py +0 -0
  153. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/test_settings.py +0 -0
  154. {spritegen_cli-0.2.0 → spritegen_cli-0.3.0}/tests/tests_fal_doubles.py +0 -0
@@ -217,3 +217,9 @@ __marimo__/
217
217
  # Streamlit
218
218
  .streamlit/secrets.toml
219
219
  assets/
220
+
221
+ # CodeGraph index — never committed
222
+ .codegraph/
223
+
224
+ # ai-jail sandbox configuration, local to a checkout
225
+ .ai-jail
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: spritegen-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Sprite generation CLI — a few AI-generated images into a game-ready character sheet
5
5
  Requires-Python: >=3.13
6
6
  Requires-Dist: av>=12.0
@@ -1,5 +1,6 @@
1
1
  ---
2
- status: accepted
2
+ status: superseded
3
+ superseded-by: 0015-record-the-call-before-the-files-it-writes
3
4
  ---
4
5
 
5
6
  # The record of a paid call is append-only JSONL
@@ -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-86]()
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:88-156]()
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:166-211]()
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:23-59]()
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:75-125]()
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/__init__.py:21-35]()
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/__init__.py:38-43]()
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/__init__.py:44-67]()
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:440-473]()
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:476-490]()
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:72-103]()
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:105-140]()
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:51-70]()
28
+ [src/spritegen/workspace.py:49-68]()
29
29
 
30
- [src/spritegen/workspace.py:142-170]()
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:440-469]()
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:491-505]()
47
+ [src/spritegen/workspace.py:527-541]()
48
48
 
49
- [src/spritegen/workspace.py:650-692]()
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:511-534]()
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:378-418]()
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spritegen-cli"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Sprite generation CLI — a few AI-generated images into a game-ready character sheet"
5
5
  requires-python = ">=3.13"
6
6
 
@@ -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
- atlas = Image.new("RGBA", (cell_w * cols, cell_h * rows), (0, 0, 0, 0))
51
- placed = []
52
- for index, (path, image) in enumerate(zip(paths, images, strict=True)):
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"), default="auto", help="where it runs"
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, workspace
152
+ from . import migrate, report, skill, upscale
152
153
 
153
154
  handlers = {}
154
155
  for module, commands in (
155
- (workspace, ("new", "status", "show", "cost", "choose")),
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, FileNotFoundError, FileExistsError, ValueError) as exc:
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
- with av.open(str(video)) as container:
21
- stream = container.streams.video[0]
22
- stream.thread_type = "AUTO"
23
- images = [frame.to_image() for frame in container.decode(stream)]
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
- total = len(images)
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
- from PIL import Image
117
-
118
- rows = (len(frames) + cols - 1) // cols
119
- width = max(frame.size[0] for frame in frames)
120
- height = max(frame.size[1] for frame in frames)
121
- board = Image.new("RGBA", (width * cols, height * rows), (0, 0, 0, 0))
122
- for index, frame in enumerate(frames):
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