spritegen-cli 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- spritegen/atlas.py +99 -0
- spritegen/cli.py +178 -0
- spritegen/clip.py +81 -0
- spritegen/drive.py +163 -0
- spritegen/endpoints.py +113 -0
- spritegen/fal.py +228 -0
- spritegen/imaging.py +219 -0
- spritegen/ledger.py +143 -0
- spritegen/matting.py +136 -0
- spritegen/migrate.py +162 -0
- spritegen/prompts.py +96 -0
- spritegen/rrdb.py +91 -0
- spritegen/settings.py +127 -0
- spritegen/sheet.py +370 -0
- spritegen/skill/__init__.py +303 -0
- spritegen/skill/files/SKILL.md +553 -0
- spritegen/stages/__init__.py +490 -0
- spritegen/stages/anchor.py +215 -0
- spritegen/stages/board.py +68 -0
- spritegen/stages/matte.py +209 -0
- spritegen/stages/motion.py +164 -0
- spritegen/stages/pose.py +172 -0
- spritegen/stages/video.py +196 -0
- spritegen/upscale.py +444 -0
- spritegen/workspace.py +852 -0
- spritegen_cli-0.1.0.dist-info/METADATA +16 -0
- spritegen_cli-0.1.0.dist-info/RECORD +30 -0
- spritegen_cli-0.1.0.dist-info/WHEEL +4 -0
- spritegen_cli-0.1.0.dist-info/entry_points.txt +2 -0
spritegen/sheet.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Close a board of animation frames into one row of a sprite sheet.
|
|
2
|
+
|
|
3
|
+
Absorbed from the `board_to_row.py` this grew out of. Three decisions in it are load-bearing,
|
|
4
|
+
and each is a way the animation breaks if it is done the other way:
|
|
5
|
+
|
|
6
|
+
1. **One grid and one palette over the whole board at once, never per frame.** Frame
|
|
7
|
+
by frame, each cell lands on its own grid and its own palette, and the animation
|
|
8
|
+
flickers.
|
|
9
|
+
2. **Cells are cut by the declared grid, not by islands of alpha.** The board arrived
|
|
10
|
+
with equal cells; finding the figures by alpha would lose where each one sits inside
|
|
11
|
+
its cell, and where it sits *is* the movement.
|
|
12
|
+
3. **One transform for the whole set.** Scale and offset come from the box that holds
|
|
13
|
+
every frame and apply equally to all of them. Anchoring each frame on its own would
|
|
14
|
+
push the rising frame back down and the walk would stand still.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
#: The default sheet cell, in pixels. A project with another one passes `--cell`.
|
|
22
|
+
CELL = 166
|
|
23
|
+
|
|
24
|
+
#: The GIF's flat backdrop. GIF has no partial alpha, and index transparency leaves a
|
|
25
|
+
#: fringe around the figure, so the preview gets a solid ground instead.
|
|
26
|
+
GIF_BACKDROP = (32, 32, 32, 255)
|
|
27
|
+
|
|
28
|
+
#: The art directions a row can be closed in, and the subdirectory each one goes in.
|
|
29
|
+
#: Pixel art is one of them and not the destination of every sprite — plenty of sprite
|
|
30
|
+
#: art is a crisp raster with an anti-aliased edge, and `as-is` covers the case where
|
|
31
|
+
#: the art arrived in the direction it was wanted in. See `board_to_row`.
|
|
32
|
+
ART_DIRECTIONS = ("as-is", "sharp", "pixel-art")
|
|
33
|
+
|
|
34
|
+
#: Below this the alpha is background smear, above it the alpha is the figure. Sprite
|
|
35
|
+
#: art of this kind has a real anti-aliased edge — in the sheet this was measured
|
|
36
|
+
#: against, 16% of a cell is partial alpha — so this is a floor and a ceiling rather
|
|
37
|
+
#: than a threshold that would make the edge binary.
|
|
38
|
+
ALPHA_FLOOR = 16
|
|
39
|
+
ALPHA_CEIL = 240
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_grid(text: str) -> tuple[int, int]:
|
|
43
|
+
"""`3x2` to `(3, 2)`."""
|
|
44
|
+
try:
|
|
45
|
+
cols, rows = (int(value) for value in text.lower().split("x", 1))
|
|
46
|
+
except ValueError:
|
|
47
|
+
raise ValueError(f"a grid is COLSxROWS, e.g. 3x2; got {text!r}") from None
|
|
48
|
+
if cols < 1 or rows < 1:
|
|
49
|
+
raise ValueError(f"a grid needs positive sides; got {text!r}")
|
|
50
|
+
return cols, rows
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def pixelate_board(image, colors: int, seed: int | None = None):
|
|
54
|
+
"""Recover the native grid of the whole board. Returns the image and what it found.
|
|
55
|
+
|
|
56
|
+
Seeded for the reason `imaging.to_pixelart` is: pixelfixer reconstructs by k-means on
|
|
57
|
+
OpenCV's global RNG, so without this two runs of one board differ.
|
|
58
|
+
"""
|
|
59
|
+
from .imaging import PIXELART_SEED, require_pixelfixer
|
|
60
|
+
|
|
61
|
+
require_pixelfixer()
|
|
62
|
+
|
|
63
|
+
import cv2
|
|
64
|
+
import numpy as np
|
|
65
|
+
from PIL import Image
|
|
66
|
+
from pixelfixer import detect
|
|
67
|
+
from pixelfixer.reconstruct import reconstruct
|
|
68
|
+
|
|
69
|
+
cv2.setRNGSeed(PIXELART_SEED if seed is None else seed)
|
|
70
|
+
rgba = np.array(image.convert("RGBA"))
|
|
71
|
+
found = detect(rgba)
|
|
72
|
+
out = Image.fromarray(
|
|
73
|
+
reconstruct(rgba, found["step_x"], found["step_y"], found["cols"], found["rows"])
|
|
74
|
+
)
|
|
75
|
+
if colors:
|
|
76
|
+
out = quantise(out, colors)
|
|
77
|
+
return out, found
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def resize_rgba(image, size: tuple[int, int], filter_):
|
|
81
|
+
"""Resize without letting the transparent pixels darken the edge.
|
|
82
|
+
|
|
83
|
+
A smooth filter weights a neighbour by distance and nothing else, and a fully
|
|
84
|
+
transparent pixel still carries an RGB — usually black, because that is what the
|
|
85
|
+
matte left behind it. Averaging that in is where the dark fringe around a shrunk
|
|
86
|
+
sprite comes from. Premultiplying makes a pixel's weight its own alpha, which is
|
|
87
|
+
what the average should have been all along.
|
|
88
|
+
|
|
89
|
+
`NEAREST` picks one pixel rather than averaging any, so it takes neither the fringe
|
|
90
|
+
nor this treatment, and goes straight through. That is also what keeps the integer
|
|
91
|
+
upscale path byte for byte what it was.
|
|
92
|
+
"""
|
|
93
|
+
from PIL import Image
|
|
94
|
+
|
|
95
|
+
if filter_ == Image.NEAREST:
|
|
96
|
+
return image.resize(size, filter_)
|
|
97
|
+
|
|
98
|
+
import numpy as np
|
|
99
|
+
|
|
100
|
+
source = np.asarray(image.convert("RGBA"), dtype=np.float32)
|
|
101
|
+
weight = source[:, :, 3:4] / 255.0
|
|
102
|
+
premultiplied = np.concatenate([source[:, :, :3] * weight, source[:, :, 3:4]], axis=2)
|
|
103
|
+
small = Image.fromarray(premultiplied.round().astype(np.uint8), "RGBA").resize(size, filter_)
|
|
104
|
+
|
|
105
|
+
out = np.asarray(small, dtype=np.float32)
|
|
106
|
+
alpha = out[:, :, 3:4] / 255.0
|
|
107
|
+
straight = np.divide(
|
|
108
|
+
out[:, :, :3], alpha, out=np.zeros_like(out[:, :, :3]), where=alpha > 0
|
|
109
|
+
)
|
|
110
|
+
merged = np.concatenate([straight, out[:, :, 3:4]], axis=2)
|
|
111
|
+
return Image.fromarray(merged.round().clip(0, 255).astype(np.uint8), "RGBA")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def fit_scale(span_w: int, span_h: int, cell_size: int, fit: bool = False) -> int | float:
|
|
115
|
+
"""The largest scale at which the figure still fits the cell.
|
|
116
|
+
|
|
117
|
+
Integer while an integer fits, because a sprite enlarged by a whole factor keeps its
|
|
118
|
+
edges and one enlarged by 1.5 does not. Below one there is no such choice to make:
|
|
119
|
+
the art is bigger than the cell and the only question is by how much it comes down,
|
|
120
|
+
so the fraction is exact and the caller reduces with a smooth filter.
|
|
121
|
+
|
|
122
|
+
**`fit` is off by default, and that is not timidity.** Reducing is the half that was
|
|
123
|
+
missing — a sharp anchor generated at 1024 px overflows a 166 cell and `max(1, ...)`
|
|
124
|
+
left it there. But `board_to_row.py`'s output on a board that overflows is a parity
|
|
125
|
+
baseline, and quietly reducing where that script did not would break the one promise
|
|
126
|
+
the port makes. So the new behaviour arrives where the new route asks for it, and
|
|
127
|
+
the old route comes out byte for byte what it was.
|
|
128
|
+
"""
|
|
129
|
+
integer = min(cell_size // span_w, cell_size // span_h)
|
|
130
|
+
if integer >= 1:
|
|
131
|
+
return integer
|
|
132
|
+
return min(cell_size / span_w, cell_size / span_h) if fit else 1
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def harden_alpha(image, floor: int = ALPHA_FLOOR, ceil: int = ALPHA_CEIL):
|
|
136
|
+
"""Cut the smear off the alpha and snap the nearly-solid to solid.
|
|
137
|
+
|
|
138
|
+
Reducing a matted figure leaves a long faint tail where the matte's own uncertainty
|
|
139
|
+
was, and at cell size that tail reads as a halo. What it must not do is make the
|
|
140
|
+
edge binary: sprite art of this kind is anti-aliased, and a figure with a hard
|
|
141
|
+
outline does not sit beside one that has none.
|
|
142
|
+
|
|
143
|
+
So the band between `floor` and `ceil` is kept and stretched over the full range,
|
|
144
|
+
everything under `floor` goes to nothing, everything over `ceil` goes to solid.
|
|
145
|
+
"""
|
|
146
|
+
import numpy as np
|
|
147
|
+
from PIL import Image
|
|
148
|
+
|
|
149
|
+
if floor >= ceil:
|
|
150
|
+
raise ValueError(f"the alpha floor {floor} is not below the ceiling {ceil}")
|
|
151
|
+
|
|
152
|
+
rgba = np.asarray(image.convert("RGBA")).copy()
|
|
153
|
+
alpha = rgba[:, :, 3].astype(np.float32)
|
|
154
|
+
stretched = (alpha - floor) * (255.0 / (ceil - floor))
|
|
155
|
+
rgba[:, :, 3] = stretched.clip(0, 255).round().astype(np.uint8)
|
|
156
|
+
return Image.fromarray(rgba, "RGBA")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def quantise(image, colors: int):
|
|
160
|
+
"""One palette over whatever it is given, alpha kept out of the vote.
|
|
161
|
+
|
|
162
|
+
No dither, for the reason `imaging.to_pixelart` gives: it scatters half-tone noise
|
|
163
|
+
across areas the reduction just made flat. Called on the assembled row rather than
|
|
164
|
+
on a cell, so all six frames share the palette and the sprite does not shift colour
|
|
165
|
+
as it walks.
|
|
166
|
+
"""
|
|
167
|
+
from PIL import Image
|
|
168
|
+
|
|
169
|
+
quantized = image.convert("RGB").quantize(colors=colors, dither=Image.Dither.NONE)
|
|
170
|
+
merged = quantized.convert("RGBA")
|
|
171
|
+
merged.putalpha(image.getchannel("A"))
|
|
172
|
+
return merged
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def cut_cells(image, cols: int, rows: int, frames: int) -> list:
|
|
176
|
+
"""Divide by the declared grid. The division remainder stays in the last cell."""
|
|
177
|
+
width, height = image.size
|
|
178
|
+
cell_w, cell_h = width / cols, height / rows
|
|
179
|
+
cells = []
|
|
180
|
+
for index in range(frames):
|
|
181
|
+
col, row = index % cols, index // cols
|
|
182
|
+
box = (
|
|
183
|
+
round(col * cell_w),
|
|
184
|
+
round(row * cell_h),
|
|
185
|
+
round((col + 1) * cell_w),
|
|
186
|
+
round((row + 1) * cell_h),
|
|
187
|
+
)
|
|
188
|
+
cells.append(image.crop(box))
|
|
189
|
+
return cells
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def union_bounds(cells: list) -> tuple[int, int, int, int]:
|
|
193
|
+
"""The box holding the figure in every frame, in cell coordinates."""
|
|
194
|
+
boxes = [cell.getbbox() for cell in cells]
|
|
195
|
+
if any(box is None for box in boxes):
|
|
196
|
+
raise ValueError("a frame came out empty: check the grid and the frame count")
|
|
197
|
+
return (
|
|
198
|
+
min(box[0] for box in boxes),
|
|
199
|
+
min(box[1] for box in boxes),
|
|
200
|
+
max(box[2] for box in boxes),
|
|
201
|
+
max(box[3] for box in boxes),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def build_row(
|
|
206
|
+
cells: list, scale: int | float | None, cell_size: int = CELL, fit: bool = False
|
|
207
|
+
) -> tuple[list, dict]:
|
|
208
|
+
"""Place every frame into a sheet cell under one shared transform.
|
|
209
|
+
|
|
210
|
+
The transform runs both ways. Enlarging is by a whole factor and by nearest
|
|
211
|
+
neighbour, which is what a sprite drawn small needs. Reducing is by the exact
|
|
212
|
+
fraction that fits and by a smooth filter, which is what a sharp anchor generated
|
|
213
|
+
at 1024 px needs — and it is the same one transform for every frame either way,
|
|
214
|
+
because scale and offset come from the box holding the whole set.
|
|
215
|
+
|
|
216
|
+
Reducing happens only under `fit`, or under a `scale` under one given outright. See
|
|
217
|
+
`fit_scale` for why the default leaves an overflow where it stands.
|
|
218
|
+
"""
|
|
219
|
+
from PIL import Image
|
|
220
|
+
|
|
221
|
+
left, top, right, bottom = union_bounds(cells)
|
|
222
|
+
span_w, span_h = right - left, bottom - top
|
|
223
|
+
if scale is None:
|
|
224
|
+
scale = fit_scale(span_w, span_h, cell_size, fit)
|
|
225
|
+
|
|
226
|
+
shrinking = scale < 1
|
|
227
|
+
filter_ = Image.LANCZOS if shrinking else Image.NEAREST
|
|
228
|
+
scaled_w = max(1, round(span_w * scale))
|
|
229
|
+
scaled_h = max(1, round(span_h * scale))
|
|
230
|
+
# Centred across, feet on the cell floor: the anchor the client draws from.
|
|
231
|
+
off_x = (cell_size - scaled_w) // 2
|
|
232
|
+
off_y = cell_size - scaled_h
|
|
233
|
+
|
|
234
|
+
placed = []
|
|
235
|
+
for cell in cells:
|
|
236
|
+
crop = cell.crop((left, top, right, bottom))
|
|
237
|
+
sized = resize_rgba(crop, (scaled_w, scaled_h), filter_)
|
|
238
|
+
target = Image.new("RGBA", (cell_size, cell_size), (0, 0, 0, 0))
|
|
239
|
+
target.paste(sized, (off_x, off_y), sized)
|
|
240
|
+
placed.append(target)
|
|
241
|
+
|
|
242
|
+
return placed, {
|
|
243
|
+
"union": f"{span_w}x{span_h}",
|
|
244
|
+
"scale": int(scale) if float(scale).is_integer() else round(float(scale), 4),
|
|
245
|
+
"placed": f"{scaled_w}x{scaled_h}",
|
|
246
|
+
"offset": f"{off_x},{off_y}",
|
|
247
|
+
"overflow": scaled_w > cell_size or scaled_h > cell_size,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def write_gif(placed: list, path: Path, fps: int = 7) -> Path:
|
|
252
|
+
"""A looping preview of the row, on a flat backdrop."""
|
|
253
|
+
from PIL import Image
|
|
254
|
+
|
|
255
|
+
flat = []
|
|
256
|
+
for frame in placed:
|
|
257
|
+
back = Image.new("RGBA", frame.size, GIF_BACKDROP)
|
|
258
|
+
back.paste(frame, (0, 0), frame)
|
|
259
|
+
flat.append(back.convert("P", palette=Image.Palette.ADAPTIVE))
|
|
260
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
flat[0].save(path, save_all=True, append_images=flat[1:], duration=round(1000 / fps), loop=0)
|
|
262
|
+
return path
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def board_to_row(
|
|
266
|
+
source: Path,
|
|
267
|
+
out: Path,
|
|
268
|
+
*,
|
|
269
|
+
grid: tuple[int, int],
|
|
270
|
+
frames: int | None = None,
|
|
271
|
+
cell: int = CELL,
|
|
272
|
+
scale: int | float | None = None,
|
|
273
|
+
fit: bool = False,
|
|
274
|
+
art: str = "as-is",
|
|
275
|
+
colors: int = 0,
|
|
276
|
+
alpha_floor: int = ALPHA_FLOOR,
|
|
277
|
+
alpha_ceil: int = ALPHA_CEIL,
|
|
278
|
+
chroma: str | None = None,
|
|
279
|
+
tol: float = 60.0,
|
|
280
|
+
feather: float = 24.0,
|
|
281
|
+
chroma_mode: str = "flood",
|
|
282
|
+
gif: Path | None = None,
|
|
283
|
+
fps: int = 7,
|
|
284
|
+
) -> dict:
|
|
285
|
+
"""A whole board through to one row of the sheet. Returns what it measured.
|
|
286
|
+
|
|
287
|
+
`chroma` cuts the key off the whole board at once, before anything is divided. The
|
|
288
|
+
same tolerance and the same flood then apply to all six cells, so every edge comes
|
|
289
|
+
out alike; done cell by cell the contour alpha varies between frames and the
|
|
290
|
+
silhouette flickers.
|
|
291
|
+
|
|
292
|
+
`art` is the direction the row is closed in. The three are not settings of one
|
|
293
|
+
thing, and none of them is the default the others fall back to:
|
|
294
|
+
|
|
295
|
+
- **`pixel-art`** recovers a native grid the art already has, through `pixelfixer`,
|
|
296
|
+
and rewrites the board on it before anything is divided. It needs blocks to find:
|
|
297
|
+
on a smooth render the vote returns nonsense, which is why the report carries
|
|
298
|
+
`native` — that number is how you check a grid was there at all.
|
|
299
|
+
- **`sharp`** assumes there is no grid, because plenty of sprite art has none. The
|
|
300
|
+
reduction into the cell *is* the resolution decision, so all this adds afterwards
|
|
301
|
+
is the alpha treatment and, where asked, one palette — over the assembled row, so
|
|
302
|
+
the frames cannot drift apart.
|
|
303
|
+
- **`as-is`** does neither, for art that already arrived in the direction it was
|
|
304
|
+
wanted in.
|
|
305
|
+
|
|
306
|
+
A sprite can be closed in more than one of them, and each goes in its own directory
|
|
307
|
+
under `sheet/`. Which one a project ships is the project's decision, not this
|
|
308
|
+
function's.
|
|
309
|
+
"""
|
|
310
|
+
from PIL import Image
|
|
311
|
+
|
|
312
|
+
cols, rows = grid
|
|
313
|
+
frames = frames or cols * rows
|
|
314
|
+
if frames > cols * rows:
|
|
315
|
+
raise ValueError(f"{frames} frames do not fit a {cols}x{rows} grid")
|
|
316
|
+
if art not in ART_DIRECTIONS:
|
|
317
|
+
raise ValueError(f"art is one of {', '.join(ART_DIRECTIONS)}; got {art!r}")
|
|
318
|
+
if colors and art == "as-is":
|
|
319
|
+
raise ValueError("colors needs an art direction to quantise into")
|
|
320
|
+
|
|
321
|
+
cut_report = None
|
|
322
|
+
if chroma:
|
|
323
|
+
from .imaging import cut_chroma, parse_chroma
|
|
324
|
+
|
|
325
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
326
|
+
cut = out.with_name(f"{out.stem}.cut.png")
|
|
327
|
+
cut.write_bytes(source.read_bytes())
|
|
328
|
+
cut_report = cut_chroma(cut, parse_chroma(chroma), tol, feather, True, 2, chroma_mode)
|
|
329
|
+
source = cut
|
|
330
|
+
|
|
331
|
+
board = Image.open(source).convert("RGBA")
|
|
332
|
+
native = None
|
|
333
|
+
if art == "pixel-art":
|
|
334
|
+
board, found = pixelate_board(board, colors)
|
|
335
|
+
native = f"{found['cols']}x{found['rows']}"
|
|
336
|
+
|
|
337
|
+
cells = cut_cells(board, cols, rows, frames)
|
|
338
|
+
# `sharp` lands the art on the cell, so a figure larger than the cell is the
|
|
339
|
+
# ordinary case there rather than a mistake, and it comes down without being asked.
|
|
340
|
+
placed, report = build_row(cells, scale, cell, fit or art == "sharp")
|
|
341
|
+
|
|
342
|
+
strip = Image.new("RGBA", (cell * len(placed), cell), (0, 0, 0, 0))
|
|
343
|
+
for index, frame in enumerate(placed):
|
|
344
|
+
strip.paste(frame, (index * cell, 0), frame)
|
|
345
|
+
|
|
346
|
+
if art == "sharp":
|
|
347
|
+
# On the whole strip and not on a cell: one palette and one alpha decision for
|
|
348
|
+
# every frame, which is the same reason `detect` runs before the board is cut.
|
|
349
|
+
strip = harden_alpha(strip, alpha_floor, alpha_ceil)
|
|
350
|
+
if colors:
|
|
351
|
+
strip = quantise(strip, colors)
|
|
352
|
+
placed = [
|
|
353
|
+
strip.crop((index * cell, 0, (index + 1) * cell, cell))
|
|
354
|
+
for index in range(len(placed))
|
|
355
|
+
]
|
|
356
|
+
|
|
357
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
358
|
+
strip.save(out)
|
|
359
|
+
|
|
360
|
+
if gif:
|
|
361
|
+
write_gif(placed, gif, fps)
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
**report,
|
|
365
|
+
"art": art,
|
|
366
|
+
"native": native,
|
|
367
|
+
"frames": len(placed),
|
|
368
|
+
"cell": cell,
|
|
369
|
+
"chroma": cut_report,
|
|
370
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""The Claude skill this CLI writes for itself — R5.1, R5.2.
|
|
2
|
+
|
|
3
|
+
One file goes out, `.claude/skills/spritegen/SKILL.md`, and it is the whole skill: how to
|
|
4
|
+
run the tool, the flow a character goes through, the prompt skeletons, and every option of
|
|
5
|
+
every stage. A skill is read by an agent that has the file and nothing else, so splitting
|
|
6
|
+
it across a reference tree buys nothing here and costs a reader who has to guess which
|
|
7
|
+
part holds the answer.
|
|
8
|
+
|
|
9
|
+
The file is half authored and half generated. The prose is shipped beside this module in
|
|
10
|
+
`files/SKILL.md`, which carries marker lines; each marker is replaced with a table read
|
|
11
|
+
out of `stages.STAGES` and `endpoints.MOTION`.
|
|
12
|
+
|
|
13
|
+
That split is the whole point of the command. A skill describing the stages in prose is a
|
|
14
|
+
second copy of the registry, and the copy is the one that goes stale: the CLI gains an
|
|
15
|
+
option, the skill keeps offering the old one, and whoever reads it spends money on a call
|
|
16
|
+
the parser rejects. What the registry knows is generated. What it cannot know — why the
|
|
17
|
+
anchor faces south, why `motion` is preferred over `video` — is prose, and stays prose.
|
|
18
|
+
|
|
19
|
+
A marker the source lost is an error rather than a silent omission: the skill would
|
|
20
|
+
otherwise ship describing no stages at all.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import sys
|
|
27
|
+
from importlib import resources
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from .. import endpoints
|
|
31
|
+
from .. import stages as registry
|
|
32
|
+
|
|
33
|
+
#: Where the skill goes, under whichever `.claude` was found.
|
|
34
|
+
SKILL_DIR = Path(".claude") / "skills" / "spritegen"
|
|
35
|
+
|
|
36
|
+
#: The one file. `files/SKILL.md` beside this module is its source.
|
|
37
|
+
SKILL_FILE = "SKILL.md"
|
|
38
|
+
|
|
39
|
+
_STAMP = (
|
|
40
|
+
"<!-- Generated by `spritegen init`. Edit the source in the spritegen\n"
|
|
41
|
+
" repository, at src/spritegen/skill/files/SKILL.md, not this copy:\n"
|
|
42
|
+
" `init --force` rewrites it. -->"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _stamp(text: str) -> str:
|
|
47
|
+
"""`text` with the generated-by note in it, under the frontmatter — R5.5.
|
|
48
|
+
|
|
49
|
+
Injected rather than authored into the source, so it cannot be edited away by
|
|
50
|
+
accident. A YAML block has to stay the first thing in the file, so the note goes
|
|
51
|
+
beneath it.
|
|
52
|
+
"""
|
|
53
|
+
if text.startswith("---\n"):
|
|
54
|
+
end = text.find("\n---\n", 4)
|
|
55
|
+
if end != -1:
|
|
56
|
+
cut = end + len("\n---\n")
|
|
57
|
+
return f"{text[:cut]}\n{_STAMP}\n{text[cut:]}"
|
|
58
|
+
return f"{_STAMP}\n\n{text}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _source() -> str:
|
|
62
|
+
"""The authored half, read out of the package rather than off a path.
|
|
63
|
+
|
|
64
|
+
It travels inside the wheel, so an installed spritegen writes the same skill as one
|
|
65
|
+
run from a checkout.
|
|
66
|
+
"""
|
|
67
|
+
return resources.files(__package__).joinpath("files", SKILL_FILE).read_text(encoding="utf-8")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _takes(option: registry.Option) -> str:
|
|
71
|
+
"""What one option accepts, as a reader needs it rather than as argparse sees it."""
|
|
72
|
+
if option.action == "store_true":
|
|
73
|
+
return "flag"
|
|
74
|
+
if option.choices:
|
|
75
|
+
return " / ".join(f"`{value}`" for value in option.choices)
|
|
76
|
+
if option.action == "append":
|
|
77
|
+
return f"`{option.kind}`, repeatable"
|
|
78
|
+
return f"`{option.kind}`"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _default(option: registry.Option) -> str:
|
|
82
|
+
"""That option's default. A `0` is kept rather than shown as absent: several of them
|
|
83
|
+
mean *the whole of it* — every cell of the row, the largest scale that fits."""
|
|
84
|
+
if option.action == "store_true":
|
|
85
|
+
return "off"
|
|
86
|
+
if option.default is None or option.default == "":
|
|
87
|
+
return "-"
|
|
88
|
+
return f"`{option.default}`"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def stage_table() -> str:
|
|
92
|
+
"""The pipeline as one table — what each stage needs, writes, and whether it spends."""
|
|
93
|
+
rows = ["| Stage | Needs | Writes | Spends |", "|---|---|---|---|"]
|
|
94
|
+
for stage in registry.STAGES:
|
|
95
|
+
needs = " or ".join(f"`{name}`" for name in stage.requires) if stage.requires else "-"
|
|
96
|
+
spends = "yes" if stage.paid else "no"
|
|
97
|
+
rows.append(f"| `{stage.name}` | {needs} | `{stage.produces}/` | {spends} |")
|
|
98
|
+
return "\n".join(rows)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def option_table(stage: registry.Stage) -> str:
|
|
102
|
+
"""Every option that stage declares."""
|
|
103
|
+
if not stage.options:
|
|
104
|
+
return "No options of its own."
|
|
105
|
+
rows = ["| Option | Takes | Default | What it does |", "|---|---|---|---|"]
|
|
106
|
+
for option in stage.options:
|
|
107
|
+
flags = ", ".join(f"`{flag}`" for flag in option.flags)
|
|
108
|
+
help_text = option.help.replace("|", r"\|")
|
|
109
|
+
rows.append(f"| {flags} | {_takes(option)} | {_default(option)} | {help_text} |")
|
|
110
|
+
return "\n".join(rows)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def stage_sections() -> str:
|
|
114
|
+
"""One section per stage: what it needs, what it writes, what it costs, its options."""
|
|
115
|
+
parts = []
|
|
116
|
+
for stage in registry.STAGES:
|
|
117
|
+
needs = " or ".join(f"`{name}`" for name in stage.requires) if stage.requires else "nothing"
|
|
118
|
+
cost = "spends money" if stage.paid else "free, and free to re-run"
|
|
119
|
+
parts += [
|
|
120
|
+
f"## `spritegen {stage.name} <asset>`",
|
|
121
|
+
"",
|
|
122
|
+
f"{stage.summary[:1].upper()}{stage.summary[1:]}. Needs {needs}, "
|
|
123
|
+
f"writes `{stage.produces}/`, {cost}.",
|
|
124
|
+
"",
|
|
125
|
+
option_table(stage),
|
|
126
|
+
"",
|
|
127
|
+
]
|
|
128
|
+
parts.append(
|
|
129
|
+
"Every stage also takes `--force`, to rewrite output it already produced, and every"
|
|
130
|
+
"\npaid one takes `--dry-run`."
|
|
131
|
+
)
|
|
132
|
+
return "\n".join(parts)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def endpoint_table() -> str:
|
|
136
|
+
"""The motion endpoints and what each one takes through `--set`.
|
|
137
|
+
|
|
138
|
+
Generated for the same reason the stages are: `--set` is checked against this schema
|
|
139
|
+
before anything is spent, so a skill quoting a stale copy of it walks the operator
|
|
140
|
+
into a rejection they pay a round trip to find out about.
|
|
141
|
+
"""
|
|
142
|
+
lines = [
|
|
143
|
+
"## The `motion` endpoints",
|
|
144
|
+
"",
|
|
145
|
+
"`--set NAME=VALUE` is checked against the chosen endpoint before the call is built,"
|
|
146
|
+
" so\nan option this table does not list is refused rather than paid for.",
|
|
147
|
+
"",
|
|
148
|
+
]
|
|
149
|
+
for name, endpoint in endpoints.MOTION.items():
|
|
150
|
+
default = " (the default)" if name == endpoints.DEFAULT else ""
|
|
151
|
+
lines += [f"**`--endpoint {name}`**{default} — `{endpoint.id}`", "", endpoint.summary, ""]
|
|
152
|
+
if not endpoint.options:
|
|
153
|
+
lines += ["Takes no option of its own.", ""]
|
|
154
|
+
continue
|
|
155
|
+
lines += ["| `--set` | Accepts | Sent unless overridden |", "|---|---|---|"]
|
|
156
|
+
for option, allowed in sorted(endpoint.options.items()):
|
|
157
|
+
takes = " / ".join(f"`{value}`" for value in allowed) if allowed else "any"
|
|
158
|
+
sent = endpoint.defaults.get(option)
|
|
159
|
+
lines.append(f"| `{option}` | {takes} | {'-' if sent is None else f'`{sent}`'} |")
|
|
160
|
+
lines.append("")
|
|
161
|
+
return "\n".join(lines).rstrip("\n")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
_FREE = """## The commands that spend nothing
|
|
165
|
+
|
|
166
|
+
| Command | What it does |
|
|
167
|
+
|---|---|
|
|
168
|
+
| `spritegen new <asset>` | open an asset directory; refuses one that already exists |
|
|
169
|
+
| `spritegen status` | every asset, the stage that completed last, the ones that could run now |
|
|
170
|
+
| `spritegen cost [<asset>]` | the paid calls, read from the ledger alone |
|
|
171
|
+
| `spritegen init [--dir <path>] [--force]` | rewrite this skill |"""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def free_commands() -> str:
|
|
175
|
+
"""The commands that are not stages. Authored, because the registry does not hold them."""
|
|
176
|
+
return _FREE
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
#: Each marker in the source, and what replaces it. All four are required — see `render`.
|
|
180
|
+
SECTIONS: dict[str, object] = {
|
|
181
|
+
"<!-- generated: stages -->": stage_table,
|
|
182
|
+
"<!-- generated: options -->": stage_sections,
|
|
183
|
+
"<!-- generated: endpoints -->": endpoint_table,
|
|
184
|
+
"<!-- generated: free -->": free_commands,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def render() -> str:
|
|
189
|
+
"""`SKILL.md`, whole — R5.1, R5.2, R5.5."""
|
|
190
|
+
text = _source()
|
|
191
|
+
for marker, section in SECTIONS.items():
|
|
192
|
+
if marker not in text:
|
|
193
|
+
raise ValueError(
|
|
194
|
+
f"{SKILL_FILE} lost its {marker} line; it would ship with that section missing"
|
|
195
|
+
)
|
|
196
|
+
text = text.replace(marker, section())
|
|
197
|
+
return _stamp(text)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _found_claude(start: Path) -> Path | None:
|
|
201
|
+
"""The nearest directory at or above `start` that already holds a `.claude`."""
|
|
202
|
+
for base in (start, *start.parents):
|
|
203
|
+
if (base / ".claude").is_dir():
|
|
204
|
+
return base
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def target(directory: str | Path | None = None, start: Path | None = None) -> Path:
|
|
209
|
+
"""The directory the skill goes in — R5.4, R5.6.
|
|
210
|
+
|
|
211
|
+
A named directory wins. Without one, the working directory and each of its parents are
|
|
212
|
+
searched for an existing `.claude`, the same way `cli.load_env` searches for a `.env`
|
|
213
|
+
and for the same reason: this tool is run from inside `spritegen/`, while the checkout
|
|
214
|
+
that wants the skill is the one above it.
|
|
215
|
+
|
|
216
|
+
With no `.claude` anywhere it refuses rather than creating one. A `.claude` is how a
|
|
217
|
+
workspace says it is a workspace, and a skill written into a directory that never had
|
|
218
|
+
one is a file nothing reads — `--dir` is the way to say where, when the answer is not
|
|
219
|
+
a workspace already there.
|
|
220
|
+
|
|
221
|
+
The search does not stop at the home directory, so run from a checkout with no
|
|
222
|
+
`.claude` of its own it finds the user's `~/.claude` and writes a skill available to
|
|
223
|
+
every project. The command prints where it wrote, so that is visible rather than
|
|
224
|
+
silent.
|
|
225
|
+
"""
|
|
226
|
+
if directory:
|
|
227
|
+
return Path(directory)
|
|
228
|
+
start = Path.cwd() if start is None else start
|
|
229
|
+
found = _found_claude(start)
|
|
230
|
+
if found is None:
|
|
231
|
+
raise FileNotFoundError(
|
|
232
|
+
f"no .claude directory at {start} or above it; name one with --dir"
|
|
233
|
+
)
|
|
234
|
+
return found / SKILL_DIR
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def write(root: Path, *, force: bool = False) -> Path:
|
|
238
|
+
"""Write the skill under `root` — R5.1, R5.3."""
|
|
239
|
+
text = render()
|
|
240
|
+
path = root / SKILL_FILE
|
|
241
|
+
if path.exists() and not force:
|
|
242
|
+
raise FileExistsError(f"{path} is already there; pass --force to rewrite it")
|
|
243
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
path.write_text(text, encoding="utf-8")
|
|
245
|
+
return path
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _confirm(question: str, assume_yes: bool) -> bool:
|
|
249
|
+
"""Ask on a terminal, and take silence for no anywhere else.
|
|
250
|
+
|
|
251
|
+
`init` writes into somebody else's project, so it asks first. What it must not do is
|
|
252
|
+
block: run from a script, a hook or a harness there is nobody at the other end, and a
|
|
253
|
+
prompt that never returns is worse than a step that did not happen. So a
|
|
254
|
+
non-interactive stdin is a no, and `--yes` is how such a run says yes in advance.
|
|
255
|
+
"""
|
|
256
|
+
if assume_yes:
|
|
257
|
+
return True
|
|
258
|
+
if not sys.stdin.isatty():
|
|
259
|
+
print(f"{question} [skipped: nothing is attached to answer; pass --yes]")
|
|
260
|
+
return False
|
|
261
|
+
return input(f"{question} [y/N] ").strip().lower() in {"y", "yes"}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
265
|
+
"""Set the project up: the asset workspace, and the skill — R5.1.
|
|
266
|
+
|
|
267
|
+
Two acts, and only the first is unconditional. The workspace is this tool's own
|
|
268
|
+
directory and nothing else reads it, so it is made without asking. The skill goes
|
|
269
|
+
into the project's `.claude`, which belongs to whoever works there, so it is asked
|
|
270
|
+
for — see `_confirm` for what happens when there is nobody to ask.
|
|
271
|
+
|
|
272
|
+
ASCII only, for the reason `workspace.cmd_status` is: a Windows console on a codepage
|
|
273
|
+
without an em dash drops the line rather than raising where anyone would see it.
|
|
274
|
+
"""
|
|
275
|
+
from .. import workspace
|
|
276
|
+
|
|
277
|
+
assume_yes = getattr(args, "yes", False)
|
|
278
|
+
force = getattr(args, "force", False)
|
|
279
|
+
|
|
280
|
+
root = workspace.assets_root()
|
|
281
|
+
if root.is_dir():
|
|
282
|
+
print(f"workspace already at {root}")
|
|
283
|
+
else:
|
|
284
|
+
root.mkdir(parents=True)
|
|
285
|
+
print(f"workspace created at {root}")
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
destination = target(getattr(args, "directory", None))
|
|
289
|
+
except FileNotFoundError:
|
|
290
|
+
# No .claude anywhere. Offer the project root rather than refusing outright:
|
|
291
|
+
# this is setup, and a project without one is the ordinary case for setup. A
|
|
292
|
+
# declined offer is still the refusal it always was, and still exits 1 — what
|
|
293
|
+
# changed is that there is now an offer to decline.
|
|
294
|
+
where = workspace.project_root() / SKILL_DIR
|
|
295
|
+
if not _confirm(f"no .claude found; install the skill at {where}?", assume_yes):
|
|
296
|
+
raise
|
|
297
|
+
destination = where
|
|
298
|
+
|
|
299
|
+
# `write` refuses a skill already there, and that refusal is the exit code the CLI
|
|
300
|
+
# reports. Checking it here as well would answer twice and disagree once.
|
|
301
|
+
path = write(destination, force=force)
|
|
302
|
+
print(f"skill written to {path} ({len(path.read_text(encoding='utf-8').splitlines())} lines)")
|
|
303
|
+
return 0
|