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.
@@ -0,0 +1,490 @@
1
+ """The stage registry — what exists, and what each one needs before it.
2
+
3
+ The pipeline is not a queue. `motion` and `video` do the same job by different routes:
4
+ one transfers movement from a driving clip, the other animates the anchor directly.
5
+ Both hand over a board, and `matte` takes either. That is why `requires` means *any
6
+ one of these* rather than *this one*.
7
+
8
+ Order is checked by `workspace`, reading from here. Each stage is implemented by the
9
+ module of the same name beside this file, resolved only when it is about to run: the
10
+ registry describes the pipeline without importing Pillow, numpy or fal_client.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from importlib import import_module
17
+ from types import ModuleType
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Option:
22
+ """One command-line option a stage takes.
23
+
24
+ Declared here rather than by the stage module so `--help` is complete without
25
+ importing five modules and their image libraries. The stage reads the value off the
26
+ parsed namespace; nothing about how it is parsed is the stage's business.
27
+ """
28
+
29
+ flags: tuple[str, ...]
30
+ help: str
31
+ kind: str = "str"
32
+ default: object = None
33
+ choices: tuple = ()
34
+ action: str | None = None
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Stage:
39
+ """One stage of the pipeline.
40
+
41
+ `requires` names the stages of which **one** is enough to be complete. Empty marks
42
+ the root.
43
+
44
+ `produces` is the **kind** of artifact, which is also the directory it goes in. Those
45
+ were one word for as long as a stage made one thing; they are not the same word any
46
+ more. `anchor` makes box art, an anchor or an icon depending on what it was asked
47
+ for, and `board` closes the same frames into as many art directions as anyone wants
48
+ — so the kind, and the directory, follow the options rather than the stage:
49
+
50
+ - `kind_option` names the option that decides the kind. Absent, the kind is
51
+ `produces` and nothing varies.
52
+ - `variant_option` names the option that decides a subdirectory under the kind. That
53
+ is a dimension rather than a step: `sheet/sharp/` and `sheet/pixel-art/` are the
54
+ same sprite closed two ways, and neither is the sheet.
55
+
56
+ Both name an option this stage declares, and `workspace.output_dir` is the only
57
+ place that turns them into a path.
58
+ """
59
+
60
+ name: str
61
+ requires: tuple[str, ...]
62
+ produces: str
63
+ summary: str
64
+ paid: bool
65
+ options: tuple[Option, ...] = ()
66
+ kind_option: str | None = None
67
+ variant_option: str | None = None
68
+
69
+
70
+ STAGES: tuple[Stage, ...] = (
71
+ Stage(
72
+ name="anchor",
73
+ requires=(),
74
+ produces="anchor",
75
+ summary="generate a single image: an anchor, box art or an icon",
76
+ paid=True,
77
+ kind_option="kind",
78
+ options=(
79
+ Option(
80
+ ("--kind",),
81
+ "what this image is, and the directory it goes in: anchor is the neutral "
82
+ "sprite every later stage animates, box-art is the illustration that "
83
+ "settles the look, icon is a single object",
84
+ default="anchor",
85
+ choices=("anchor", "box-art", "icon"),
86
+ ),
87
+ Option(("--prompt",), "the prompt itself"),
88
+ Option(("--prompt-file",), "a file holding the prompt"),
89
+ Option(
90
+ ("--ref",),
91
+ "a reference image; repeat it, and the prompt addresses each one in order",
92
+ action="append",
93
+ ),
94
+ Option(
95
+ ("--size",),
96
+ "WIDTHxHEIGHT, or a fal preset (square_hd, square, portrait_4_3, "
97
+ "portrait_16_9, landscape_4_3, landscape_16_9, auto)",
98
+ default="1024x1024",
99
+ ),
100
+ Option(
101
+ ("--quality",),
102
+ "the endpoint's tier",
103
+ default="low",
104
+ choices=("low", "medium", "high"),
105
+ ),
106
+ Option(("--count",), "how many images to generate", kind="int", default=1),
107
+ Option(("--transparent",), "cut the chroma background to alpha", action="store_true"),
108
+ Option(
109
+ ("--chroma",),
110
+ "the key colour, 6 hex digits; with --transparent the prompt asks for "
111
+ "this field and the cut then removes it",
112
+ default="00b140",
113
+ ),
114
+ Option(("--tol",), "chroma tolerance", kind="float", default=60.0),
115
+ Option(("--feather",), "the alpha band at the edge", kind="float", default=24.0),
116
+ Option(
117
+ ("--chroma-mode",),
118
+ "flood takes only what the border reaches; global takes the key anywhere",
119
+ default="flood",
120
+ choices=("flood", "global"),
121
+ ),
122
+ Option(("--despill",), "pull the leaked key out of edge pixels", action="store_true"),
123
+ Option(
124
+ ("--despill-radius",),
125
+ "how far from transparent to despill",
126
+ kind="int",
127
+ default=2,
128
+ ),
129
+ Option(
130
+ ("--pixelart",),
131
+ "recover the native pixel grid and rewrite on it",
132
+ action="store_true",
133
+ ),
134
+ Option(("--colors",), "quantise to N colours with --pixelart", kind="int", default=0),
135
+ Option(
136
+ ("--scale",),
137
+ "integer upscale after the grid is recovered",
138
+ kind="int",
139
+ default=1,
140
+ ),
141
+ ),
142
+ ),
143
+ Stage(
144
+ name="motion",
145
+ requires=("anchor",),
146
+ produces="frames",
147
+ summary="transfer movement from a driving clip onto the anchor or a pose",
148
+ paid=True,
149
+ options=(
150
+ Option(
151
+ ("--pose",),
152
+ "start the clip from this animation's pose instead of the anchor, and "
153
+ "end it on that pose's last frame",
154
+ ),
155
+ Option(
156
+ ("--sheet",),
157
+ "the reference sheet the movement is taken from; a game asset, not the "
158
+ "asset's own, which is why it is named here",
159
+ ),
160
+ Option(("--row",), "which row of the sheet, one animation each", kind="int", default=0),
161
+ Option(
162
+ ("--sheet-frames",),
163
+ "how many cells of that row (default: the whole row)",
164
+ kind="int",
165
+ default=0,
166
+ ),
167
+ Option(("--clip",), "a driving clip to use instead of building one from a sheet"),
168
+ Option(
169
+ ("--endpoint",),
170
+ "which motion endpoint",
171
+ default="wan-motion",
172
+ choices=("wan-motion", "wan-animate", "one-to-all"),
173
+ ),
174
+ Option(("--prompt",), "an optional hint, where the endpoint takes one", default=""),
175
+ Option(("--target",), "the driving clip's square side", kind="int", default=720),
176
+ Option(("--drive-fps",), "the driving clip's frame rate", kind="int", default=12),
177
+ Option(("--seconds",), "how long the driving clip runs", kind="float", default=4.0),
178
+ Option(
179
+ ("--frames",),
180
+ "how many frames to pull out of the result",
181
+ kind="int",
182
+ default=6,
183
+ ),
184
+ Option(("--start",), "where extraction begins, 0..1", kind="float", default=0.0),
185
+ Option(("--end",), "where extraction ends, 0..1", kind="float", default=1.0),
186
+ Option(("--pick",), "exact frame indices, comma-separated"),
187
+ Option(("--cols",), "how wide to pack the board", kind="int", default=3),
188
+ Option(
189
+ ("--set",),
190
+ "an endpoint option, NAME=VALUE; checked against that endpoint before "
191
+ "anything is spent",
192
+ action="append",
193
+ ),
194
+ ),
195
+ ),
196
+ Stage(
197
+ name="pose",
198
+ requires=("anchor",),
199
+ produces="pose",
200
+ summary="the frame an animation starts from, and the one it ends on",
201
+ paid=True,
202
+ # Not `--name`: the asset is already the positional `name`, and argparse would
203
+ # write both to the same attribute — the pose would be generated for an asset
204
+ # called "attack".
205
+ variant_option="animation",
206
+ options=(
207
+ Option(
208
+ ("--animation",),
209
+ "which animation this pose is for, and the directory it goes in "
210
+ "under pose/ — attack, cast, death",
211
+ default="",
212
+ ),
213
+ Option(("--prompt",), "the starting pose"),
214
+ Option(("--prompt-file",), "a file holding it"),
215
+ Option(
216
+ ("--end-prompt",),
217
+ "a different last frame; without it the last frame is the first, so the "
218
+ "cycle closes where it began",
219
+ ),
220
+ Option(("--end-prompt-file",), "a file holding the last frame's prompt"),
221
+ Option(
222
+ ("--no-end",),
223
+ "write no last frame at all, for a movement that does not return",
224
+ action="store_true",
225
+ ),
226
+ Option(
227
+ ("--ref",),
228
+ "an extra reference after the anchor, which is always the first",
229
+ action="append",
230
+ ),
231
+ Option(("--size",), "WIDTHxHEIGHT, or a fal preset", default="1024x1024"),
232
+ Option(
233
+ ("--quality",),
234
+ "the endpoint's tier",
235
+ default="low",
236
+ choices=("low", "medium", "high"),
237
+ ),
238
+ Option(("--transparent",), "cut the chroma background to alpha", action="store_true"),
239
+ Option(("--chroma",), "the key colour, 6 hex digits", default="00b140"),
240
+ Option(("--tol",), "chroma tolerance", kind="float", default=60.0),
241
+ Option(("--feather",), "the alpha band at the edge", kind="float", default=24.0),
242
+ Option(
243
+ ("--chroma-mode",),
244
+ "flood takes only what the border reaches; global takes the key anywhere",
245
+ default="flood",
246
+ choices=("flood", "global"),
247
+ ),
248
+ Option(("--despill",), "pull the leaked key out of edge pixels", action="store_true"),
249
+ Option(
250
+ ("--despill-radius",),
251
+ "how far from transparent to despill",
252
+ kind="int",
253
+ default=2,
254
+ ),
255
+ ),
256
+ ),
257
+ Stage(
258
+ name="video",
259
+ requires=("anchor",),
260
+ produces="frames",
261
+ summary="animate the anchor or a pose directly, with no driving clip",
262
+ paid=True,
263
+ options=(
264
+ Option(
265
+ ("--pose",),
266
+ "start the clip from this animation's pose instead of the anchor, and "
267
+ "end it on that pose's last frame",
268
+ ),
269
+ Option(("--prompt",), "the movement to animate"),
270
+ Option(("--prompt-file",), "a file holding the prompt"),
271
+ Option(
272
+ ("--model",),
273
+ "which video endpoint",
274
+ default="grok",
275
+ choices=("grok", "grok-i2v", "seedance", "kling"),
276
+ ),
277
+ Option(
278
+ ("--ref",),
279
+ "an extra reference for grok, after the anchor; the prompt addresses "
280
+ "each by <IMAGE_0>, <IMAGE_1> in order",
281
+ action="append",
282
+ ),
283
+ Option(
284
+ ("--loop",),
285
+ "send the anchor as the last frame too, so the cycle closes where it began",
286
+ action="store_true",
287
+ ),
288
+ Option(("--duration",), "seconds of output", kind="int", default=4),
289
+ Option(
290
+ ("--resolution",),
291
+ "clip resolution",
292
+ default="480p",
293
+ choices=("480p", "720p", "1080p"),
294
+ ),
295
+ Option(
296
+ ("--aspect",),
297
+ "clip aspect for grok",
298
+ default="1:1",
299
+ choices=("16:9", "4:3", "3:2", "1:1", "2:3", "3:4", "9:16"),
300
+ ),
301
+ Option(
302
+ ("--negative",),
303
+ "negative prompt, where the endpoint takes one",
304
+ default="blur, distort, low quality, camera movement",
305
+ ),
306
+ Option(("--frames",), "how many frames to pull out", kind="int", default=6),
307
+ Option(
308
+ ("--start",),
309
+ "where extraction begins, 0..1 of the clip; the first frame of an "
310
+ "image-to-video clip is the input image standing still",
311
+ kind="float",
312
+ default=0.0,
313
+ ),
314
+ Option(("--end",), "where extraction ends, 0..1", kind="float", default=1.0),
315
+ Option(
316
+ ("--pick",),
317
+ "exact frame indices, comma-separated; ignores frames, start and end",
318
+ ),
319
+ Option(("--cols",), "how wide to pack the board", kind="int", default=3),
320
+ ),
321
+ ),
322
+ Stage(
323
+ name="matte",
324
+ requires=("motion", "video"),
325
+ produces="cutout",
326
+ summary="cut the frames' background by segmentation, locally or paid",
327
+ paid=True,
328
+ options=(
329
+ Option(
330
+ ("--backend",),
331
+ "local runs BiRefNet here through rembg and costs nothing; fal is the "
332
+ "paid endpoint. Without this, whatever settings say (local by default)",
333
+ choices=("local", "fal"),
334
+ ),
335
+ Option(
336
+ ("--model",),
337
+ "the rembg weights, with --backend local; without it, what settings say",
338
+ ),
339
+ Option(
340
+ ("--variant",),
341
+ "birefnet weights at the paid endpoint; Matting gives continuous alpha, "
342
+ "which is what hair needs",
343
+ default="Matting",
344
+ choices=(
345
+ "General Use (Light)",
346
+ "General Use (Light 2K)",
347
+ "General Use (Heavy)",
348
+ "Matting",
349
+ "Portrait",
350
+ "General Use (Dynamic)",
351
+ ),
352
+ ),
353
+ Option(
354
+ ("--resolution",),
355
+ "operating resolution",
356
+ default="2048x2048",
357
+ choices=("1024x1024", "2048x2048", "2304x2304"),
358
+ ),
359
+ Option(
360
+ ("--no-refine",),
361
+ "turn off edge colour decontamination, which is what kills the halo",
362
+ action="store_true",
363
+ ),
364
+ Option(("--mask",), "write the alpha mask beside the cut", action="store_true"),
365
+ Option(
366
+ ("--no-join",),
367
+ "matte each image on its own instead of joining them into one call",
368
+ action="store_true",
369
+ ),
370
+ ),
371
+ ),
372
+ Stage(
373
+ name="board",
374
+ requires=("matte",),
375
+ produces="sheet",
376
+ summary="close the frames into a sheet row, in square cells",
377
+ paid=False,
378
+ variant_option="art",
379
+ options=(
380
+ Option(("--grid",), "the board's grid, COLSxROWS", default="3x2"),
381
+ Option(
382
+ ("--frames",),
383
+ "how many cells to read (default: the whole grid)",
384
+ kind="int",
385
+ default=0,
386
+ ),
387
+ Option(("--cell",), "the sheet cell, in pixels", kind="int", default=166),
388
+ Option(
389
+ ("--scale",),
390
+ "the scale (default: the largest that fits — a whole factor while one "
391
+ "fits, and the exact fraction that brings a larger figure down)",
392
+ kind="float",
393
+ default=0.0,
394
+ ),
395
+ Option(
396
+ ("--fit",),
397
+ "bring a figure larger than the cell down to it; implied by "
398
+ "--art sharp, and off otherwise so an overflow stays visible",
399
+ action="store_true",
400
+ ),
401
+ Option(
402
+ ("--art",),
403
+ "the art direction to close this row in, and the directory under "
404
+ "sheet/ it goes in: sharp lands a crisp render on the cell, pixel-art "
405
+ "recovers a native grid the art already has, as-is does neither",
406
+ default="as-is",
407
+ choices=("as-is", "sharp", "pixel-art"),
408
+ ),
409
+ Option(("--colors",), "quantise the whole row to N colours", kind="int", default=0),
410
+ Option(
411
+ ("--alpha-floor",),
412
+ "alpha under this is background smear and goes to nothing, with --art sharp",
413
+ kind="int",
414
+ default=16,
415
+ ),
416
+ Option(
417
+ ("--alpha-ceil",),
418
+ "alpha over this is the figure and goes to solid, with --art sharp",
419
+ kind="int",
420
+ default=240,
421
+ ),
422
+ Option(("--chroma",), "cut this key off the whole board first, 6 hex digits"),
423
+ Option(("--tol",), "chroma tolerance", kind="float", default=60.0),
424
+ Option(("--feather",), "the alpha band at the edge", kind="float", default=24.0),
425
+ Option(
426
+ ("--chroma-mode",),
427
+ "flood takes only what the border reaches; global takes the key anywhere",
428
+ default="flood",
429
+ choices=("flood", "global"),
430
+ ),
431
+ Option(("--no-gif",), "skip the looping preview", action="store_true"),
432
+ Option(("--fps",), "the preview's frames per second", kind="int", default=7),
433
+ ),
434
+ ),
435
+ )
436
+
437
+ BY_NAME: dict[str, Stage] = {stage.name: stage for stage in STAGES}
438
+
439
+
440
+ def get(name: str) -> Stage:
441
+ """The stage called `name`.
442
+
443
+ Fails with the full list rather than a KeyError: whoever mistyped wants to know
444
+ what exists, not which key was missing.
445
+ """
446
+ try:
447
+ return BY_NAME[name]
448
+ except KeyError:
449
+ known = ", ".join(stage.name for stage in STAGES)
450
+ raise KeyError(f"unknown stage {name!r}; there are: {known}") from None
451
+
452
+
453
+ def order() -> tuple[str, ...]:
454
+ """Every stage, each after a prerequisite of its own.
455
+
456
+ This is presentation order, not a path to walk: `motion` and `video` are
457
+ alternatives and both appear.
458
+ """
459
+ done: list[str] = []
460
+ pending = [stage.name for stage in STAGES]
461
+ while pending:
462
+ ready = [
463
+ name
464
+ for name in pending
465
+ if not BY_NAME[name].requires
466
+ or any(dep in done for dep in BY_NAME[name].requires)
467
+ ]
468
+ if not ready:
469
+ raise RuntimeError(f"cycle in the stage registry, stuck on: {pending}")
470
+ for name in ready:
471
+ done.append(name)
472
+ pending.remove(name)
473
+ return tuple(done)
474
+
475
+
476
+ def implementation(name: str) -> ModuleType:
477
+ """The module implementing `name`, imported now and not before.
478
+
479
+ A stage that has not been written yet fails here, naming the module that is
480
+ missing — not at package import, which would take the whole CLI down over one
481
+ stage.
482
+ """
483
+ stage = get(name)
484
+ try:
485
+ return import_module(f"spritegen.stages.{stage.name}")
486
+ except ModuleNotFoundError as exc:
487
+ raise NotImplementedError(
488
+ f"stage {stage.name!r} has no implementation yet "
489
+ f"(spritegen/stages/{stage.name}.py)"
490
+ ) from exc