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/__init__.py
ADDED
spritegen/atlas.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Join several images into one, and cut the result back apart.
|
|
2
|
+
|
|
3
|
+
Why it exists: a segmentation endpoint charges per call and decides per image. Sending
|
|
4
|
+
six frames separately is six charges *and* six independent decisions about where the
|
|
5
|
+
edge is, so the contour alpha varies between frames and the silhouette flickers — the
|
|
6
|
+
same failure `sheet` avoids by recovering one grid and one palette across a whole board.
|
|
7
|
+
Joining first makes the endpoint answer once, for all of them.
|
|
8
|
+
|
|
9
|
+
What it costs: the endpoint operates at a fixed resolution. Four 1024 images join into
|
|
10
|
+
a 2048 atlas, which is exactly the default operating resolution and loses nothing; four
|
|
11
|
+
1400 boards join into 2800 and lose about a quarter of their detail on the way in.
|
|
12
|
+
`fits` is how a caller finds that out before spending.
|
|
13
|
+
|
|
14
|
+
Cells are uniform — the largest source, in both directions — so a mixed set still cuts
|
|
15
|
+
back apart exactly. Each image keeps its own size in the record, and `unpack` crops to
|
|
16
|
+
it rather than to the cell.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def grid_for(count: int) -> tuple[int, int]:
|
|
26
|
+
"""The squarest grid holding `count` cells.
|
|
27
|
+
|
|
28
|
+
Squarest rather than one row: a long strip wastes the endpoint's resolution on empty
|
|
29
|
+
width, and some segmenters treat an extreme aspect ratio as a reason to letterbox.
|
|
30
|
+
"""
|
|
31
|
+
if count < 1:
|
|
32
|
+
raise ValueError("an atlas needs at least one image")
|
|
33
|
+
cols = math.ceil(math.sqrt(count))
|
|
34
|
+
rows = math.ceil(count / cols)
|
|
35
|
+
return cols, rows
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def pack(paths: list[Path], out: Path) -> dict:
|
|
39
|
+
"""Write one atlas holding every image, and return where each of them went."""
|
|
40
|
+
from PIL import Image
|
|
41
|
+
|
|
42
|
+
if not paths:
|
|
43
|
+
raise ValueError("nothing to pack")
|
|
44
|
+
|
|
45
|
+
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
|
+
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
|
+
)
|
|
58
|
+
|
|
59
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
atlas.save(out)
|
|
61
|
+
return {
|
|
62
|
+
"atlas": out.name,
|
|
63
|
+
"size": f"{atlas.width}x{atlas.height}",
|
|
64
|
+
"cell": f"{cell_w}x{cell_h}",
|
|
65
|
+
"grid": f"{cols}x{rows}",
|
|
66
|
+
"placed": placed,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def unpack(atlas: Path, layout: dict, out_dir: Path) -> list[Path]:
|
|
71
|
+
"""Cut the matted atlas back into the images it was packed from.
|
|
72
|
+
|
|
73
|
+
Each one is cropped to the box it occupied, not to the cell, so an image smaller
|
|
74
|
+
than the cell does not come back with a transparent margin it never had.
|
|
75
|
+
"""
|
|
76
|
+
from PIL import Image
|
|
77
|
+
|
|
78
|
+
image = Image.open(atlas).convert("RGBA")
|
|
79
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
|
|
81
|
+
written = []
|
|
82
|
+
for entry in layout["placed"]:
|
|
83
|
+
left, top, right, bottom = entry["box"]
|
|
84
|
+
if right > image.width or bottom > image.height:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"{entry['name']} sat at {entry['box']} but the matted atlas is "
|
|
87
|
+
f"{image.width}x{image.height}; the endpoint resized it"
|
|
88
|
+
)
|
|
89
|
+
path = out_dir / entry["name"]
|
|
90
|
+
image.crop((left, top, right, bottom)).save(path)
|
|
91
|
+
written.append(path)
|
|
92
|
+
return written
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def fits(layout: dict, resolution: str) -> bool:
|
|
96
|
+
"""Whether the atlas is within the endpoint's operating resolution — R2.6."""
|
|
97
|
+
atlas_w, atlas_h = (int(part) for part in layout["size"].split("x"))
|
|
98
|
+
limit_w, limit_h = (int(part) for part in resolution.split("x"))
|
|
99
|
+
return atlas_w <= limit_w and atlas_h <= limit_h
|
spritegen/cli.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""The sub-command table, and nothing else.
|
|
2
|
+
|
|
3
|
+
Two kinds of sub-command, and the difference matters: `new`, `status` and `cost` talk
|
|
4
|
+
about the workspace and are free; the names in `stages.STAGES` run a stage and most of
|
|
5
|
+
them spend money. The table is built from the registry, so a new stage shows up in the
|
|
6
|
+
help without touching this file.
|
|
7
|
+
|
|
8
|
+
No stage is imported at start-up. `stages.implementation` resolves the module only once
|
|
9
|
+
the command has been chosen — a stage that does not exist yet takes down that command,
|
|
10
|
+
not the whole CLI.
|
|
11
|
+
|
|
12
|
+
Nothing here reads configuration. `settings.load()` does, wherever it is needed, and it
|
|
13
|
+
finds a `.env` at or above the working directory on its own — so `FAL_KEY` can live in a
|
|
14
|
+
file beside the assets instead of in every shell that runs a stage, without the CLI
|
|
15
|
+
having to push it into the process first.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import sys
|
|
22
|
+
from collections.abc import Sequence
|
|
23
|
+
|
|
24
|
+
from . import __version__, stages
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
28
|
+
"""The full parser, with one sub-command per registered stage."""
|
|
29
|
+
parser = argparse.ArgumentParser(
|
|
30
|
+
prog="spritegen",
|
|
31
|
+
description="Turn a handful of images into a character sheet, one stage at a time.",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument("--version", action="version", version=f"spritegen {__version__}")
|
|
34
|
+
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
|
35
|
+
|
|
36
|
+
new = sub.add_parser("new", help="open a new asset")
|
|
37
|
+
new.add_argument("name", help="the character's name, and the asset directory's")
|
|
38
|
+
|
|
39
|
+
status = sub.add_parser("status", help="where each asset stopped, and what comes next")
|
|
40
|
+
status.add_argument("--json", action="store_true", help="for another program to read")
|
|
41
|
+
|
|
42
|
+
show = sub.add_parser("show", help="what one asset holds, and what comes next")
|
|
43
|
+
show.add_argument("name", help="the asset")
|
|
44
|
+
show.add_argument("--json", action="store_true", help="for another program to read")
|
|
45
|
+
|
|
46
|
+
cost = sub.add_parser("cost", help="the paid calls per asset, read from the ledger")
|
|
47
|
+
cost.add_argument("name", nargs="?", help="one asset; without it, all of them")
|
|
48
|
+
|
|
49
|
+
upscale = sub.add_parser("upscale", help="enlarge an artifact locally, for nothing")
|
|
50
|
+
upscale.add_argument("name", help="the asset")
|
|
51
|
+
upscale.add_argument(
|
|
52
|
+
"--artifact", help="which one; without it, whatever the asset produced last"
|
|
53
|
+
)
|
|
54
|
+
upscale.add_argument("--scale", type=int, default=2, help="the factor, 2 or more")
|
|
55
|
+
upscale.add_argument(
|
|
56
|
+
"--model", choices=("anime", "general"), help="which weights; default from settings"
|
|
57
|
+
)
|
|
58
|
+
upscale.add_argument(
|
|
59
|
+
"--device", choices=("auto", "cuda", "cpu"), default="auto", help="where it runs"
|
|
60
|
+
)
|
|
61
|
+
upscale.add_argument("--dry-run", action="store_true", help="print the files and touch none")
|
|
62
|
+
|
|
63
|
+
migrate = sub.add_parser("migrate", help="move an asset to the current on-disk layout")
|
|
64
|
+
migrate.add_argument("name", nargs="?", help="one asset; without it, every outdated one")
|
|
65
|
+
migrate.add_argument(
|
|
66
|
+
"--dry-run", action="store_true", help="print the moves and touch nothing"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
init = sub.add_parser("init", help="write the Claude skill describing this pipeline")
|
|
70
|
+
init.add_argument(
|
|
71
|
+
"--dir",
|
|
72
|
+
dest="directory",
|
|
73
|
+
help="where the skill goes; without it, the first .claude at or above the "
|
|
74
|
+
"working directory",
|
|
75
|
+
)
|
|
76
|
+
init.add_argument("--force", action="store_true", help="rewrite a skill already there")
|
|
77
|
+
init.add_argument(
|
|
78
|
+
"--yes",
|
|
79
|
+
action="store_true",
|
|
80
|
+
help="answer yes to what init would otherwise ask, for a run with nobody at it",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
for stage in stages.STAGES:
|
|
84
|
+
run = sub.add_parser(stage.name, help=stage.summary)
|
|
85
|
+
run.add_argument("name", help="the asset")
|
|
86
|
+
run.add_argument(
|
|
87
|
+
"--force",
|
|
88
|
+
action="store_true",
|
|
89
|
+
help=f"rewrite {stage.produces!r} even though it already exists",
|
|
90
|
+
)
|
|
91
|
+
if stage.paid:
|
|
92
|
+
run.add_argument(
|
|
93
|
+
"--dry-run",
|
|
94
|
+
action="store_true",
|
|
95
|
+
help="print the payload and spend nothing",
|
|
96
|
+
)
|
|
97
|
+
for option in stage.options:
|
|
98
|
+
run.add_argument(*option.flags, **_option_kwargs(option))
|
|
99
|
+
|
|
100
|
+
return parser
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
_KINDS = {"str": str, "int": int, "float": float}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _option_kwargs(option: stages.Option) -> dict:
|
|
107
|
+
"""One declared option, as argparse takes it."""
|
|
108
|
+
kwargs: dict = {"help": option.help, "default": option.default}
|
|
109
|
+
if option.action:
|
|
110
|
+
kwargs["action"] = option.action
|
|
111
|
+
if option.action == "store_true":
|
|
112
|
+
kwargs["default"] = bool(option.default)
|
|
113
|
+
return kwargs
|
|
114
|
+
else:
|
|
115
|
+
kwargs["type"] = _KINDS[option.kind]
|
|
116
|
+
if option.choices:
|
|
117
|
+
kwargs["choices"] = list(option.choices)
|
|
118
|
+
return kwargs
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def dispatch(args: argparse.Namespace) -> int:
|
|
122
|
+
"""Run the parsed command.
|
|
123
|
+
|
|
124
|
+
Returns the exit code rather than exiting: `main` is what talks to the process, and
|
|
125
|
+
a test calls this directly.
|
|
126
|
+
"""
|
|
127
|
+
if args.command in stages.BY_NAME:
|
|
128
|
+
module = stages.implementation(args.command)
|
|
129
|
+
return int(module.run(args))
|
|
130
|
+
|
|
131
|
+
handlers = _free_commands()
|
|
132
|
+
if args.command not in handlers:
|
|
133
|
+
raise NotImplementedError(f"command {args.command!r} has no implementation yet")
|
|
134
|
+
return int(handlers[args.command](args))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _free_commands() -> dict:
|
|
138
|
+
"""The commands that are not stages, imported late for the same reason the stages are.
|
|
139
|
+
|
|
140
|
+
`new`, `status` and `cost` are about one asset; `migrate` is about the shape of the
|
|
141
|
+
directory it lives in; `init` is about the tool itself and lives with the skill it
|
|
142
|
+
writes. All three modules are cheap to import, which is what makes a free command
|
|
143
|
+
answer without loading Pillow, numpy or fal_client.
|
|
144
|
+
"""
|
|
145
|
+
from . import migrate, skill, upscale, workspace
|
|
146
|
+
|
|
147
|
+
handlers = {}
|
|
148
|
+
for module, commands in (
|
|
149
|
+
(workspace, ("new", "status", "show", "cost")),
|
|
150
|
+
(skill, ("init",)),
|
|
151
|
+
(migrate, ("migrate",)),
|
|
152
|
+
(upscale, ("upscale",)),
|
|
153
|
+
):
|
|
154
|
+
for command in commands:
|
|
155
|
+
handler = getattr(module, f"cmd_{command}", None)
|
|
156
|
+
if handler is not None:
|
|
157
|
+
handlers[command] = handler
|
|
158
|
+
return handlers
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
162
|
+
parser = build_parser()
|
|
163
|
+
args = parser.parse_args(argv)
|
|
164
|
+
if args.command is None:
|
|
165
|
+
parser.print_help()
|
|
166
|
+
return 2
|
|
167
|
+
try:
|
|
168
|
+
return dispatch(args)
|
|
169
|
+
except NotImplementedError as exc:
|
|
170
|
+
print(f"spritegen: {exc}", file=sys.stderr)
|
|
171
|
+
return 3
|
|
172
|
+
except (KeyError, FileNotFoundError, FileExistsError, ValueError) as exc:
|
|
173
|
+
print(f"spritegen: {exc}", file=sys.stderr)
|
|
174
|
+
return 1
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
raise SystemExit(main())
|
spritegen/clip.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Turn a generated clip into a board of animation frames.
|
|
2
|
+
|
|
3
|
+
Absorbed from the `gen_video.py` this grew out of, minus the paid call. What a video
|
|
4
|
+
model gives that a grid of images does not is real movement, with contrast between contact and
|
|
5
|
+
passing. What it takes away is that the output is not pixel art: the codec interpolates,
|
|
6
|
+
the scale drifts across the clip, and the model invents background and moves the camera.
|
|
7
|
+
That is why the prompt pins the camera, and why the board exists at all — `sheet`
|
|
8
|
+
recovers one grid over every frame at once, which per-frame extraction could not do.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def decode_frames(video: Path) -> list:
|
|
17
|
+
"""Every frame of the clip, in order."""
|
|
18
|
+
import av
|
|
19
|
+
|
|
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)]
|
|
24
|
+
if not images:
|
|
25
|
+
raise ValueError(f"{video} has no frames in it")
|
|
26
|
+
return images
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_pick(text: str | None) -> list[int] | None:
|
|
30
|
+
"""`12,17,22` to `[12, 17, 22]`."""
|
|
31
|
+
if not text:
|
|
32
|
+
return None
|
|
33
|
+
try:
|
|
34
|
+
return [int(part) for part in text.split(",")]
|
|
35
|
+
except ValueError:
|
|
36
|
+
raise ValueError(f"frame indices are comma-separated whole numbers; got {text!r}") from None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def choose_frames(
|
|
40
|
+
images: list, count: int, start: float, end: float, pick: list[int] | None
|
|
41
|
+
) -> tuple[list, list[int]]:
|
|
42
|
+
"""Pick the frames of the cycle. Returns the images and the indices chosen.
|
|
43
|
+
|
|
44
|
+
The start of the clip is not the start of the cycle. In an image-to-video model the
|
|
45
|
+
first frame *is* the input image, standing still, and the movement only engages
|
|
46
|
+
after it — so extracting from zero spends one of six frames on a pose that does not
|
|
47
|
+
belong to the cycle. `start` cuts that entry off.
|
|
48
|
+
"""
|
|
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)]
|
|
66
|
+
return [images[index] for index in indices], indices
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def pack_board(frames: list, cols: int, out: Path) -> tuple[int, int]:
|
|
70
|
+
"""Lay the chosen frames out in a grid, so `sheet` can read one grid over them all."""
|
|
71
|
+
from PIL import Image
|
|
72
|
+
|
|
73
|
+
rows = (len(frames) + cols - 1) // cols
|
|
74
|
+
width = max(frame.size[0] for frame in frames)
|
|
75
|
+
height = max(frame.size[1] for frame in frames)
|
|
76
|
+
board = Image.new("RGBA", (width * cols, height * rows), (0, 0, 0, 0))
|
|
77
|
+
for index, frame in enumerate(frames):
|
|
78
|
+
board.paste(frame.convert("RGBA"), ((index % cols) * width, (index // cols) * height))
|
|
79
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
board.save(out)
|
|
81
|
+
return cols, rows
|
spritegen/drive.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Build the clip that drives a motion-transfer endpoint, out of a reference sheet.
|
|
2
|
+
|
|
3
|
+
A pose-driven model takes two things: one image of the character, and a clip whose
|
|
4
|
+
movement it copies. This builds the second from a reference sheet the project already
|
|
5
|
+
has, so the
|
|
6
|
+
movement a new character inherits is movement that was measured rather than movement the
|
|
7
|
+
model invented. That is the whole difference from the `video` stage, which asks a model
|
|
8
|
+
to make up a walk and then hopes the result is a cycle.
|
|
9
|
+
|
|
10
|
+
Three things this has to get right, and each is a way the endpoint fails otherwise:
|
|
11
|
+
|
|
12
|
+
1. **Nearest neighbour, at an integer factor.** The endpoint works at its own resolution,
|
|
13
|
+
far above a 166 px cell. On a sheet that really is pixel art, smooth resampling turns
|
|
14
|
+
flat blocks into gradients and a pose extractor reading a blurred silhouette finds a
|
|
15
|
+
different skeleton every frame. Note that not every reference sheet is pixel art:
|
|
16
|
+
measured on the one this was built against, a single 166 px cell held 1290 distinct
|
|
17
|
+
colours and changed colour 128 times across a scanline, which is an antialiased render
|
|
18
|
+
rather than flat blocks. Nearest neighbour costs nothing there and is what a pixel-art
|
|
19
|
+
sheet needs, so it stays either way.
|
|
20
|
+
2. **A flat opaque backdrop.** Video has no alpha, so a transparent cell encodes as
|
|
21
|
+
black — or as whatever the codec decides — and the figure's own dark outline stops
|
|
22
|
+
being separable from it.
|
|
23
|
+
3. **Long enough to be a clip.** Six frames at 7 fps is under a second; the endpoints
|
|
24
|
+
want seconds. The cycle repeats to fill the duration, which is safe precisely because
|
|
25
|
+
it *is* a cycle: the sheet's last frame already meets its first.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
#: The reference sheet's cell, matching `sheet.CELL`.
|
|
33
|
+
CELL = 166
|
|
34
|
+
|
|
35
|
+
#: Mid grey. Far from skin, from the outline black these sheets use, and from the chroma
|
|
36
|
+
#: green the anchor stage asks for, so nothing in the figure disappears into it.
|
|
37
|
+
BACKDROP = (110, 110, 110)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cells_from_sheet(path: Path, cell: int = CELL) -> list:
|
|
41
|
+
"""Every cell of the sheet, left to right then top to bottom."""
|
|
42
|
+
from PIL import Image
|
|
43
|
+
|
|
44
|
+
sheet = Image.open(path).convert("RGBA")
|
|
45
|
+
cols, rows = sheet.width // cell, sheet.height // cell
|
|
46
|
+
if cols < 1 or rows < 1:
|
|
47
|
+
raise ValueError(f"{path} is {sheet.width}x{sheet.height}, smaller than one {cell} cell")
|
|
48
|
+
return [
|
|
49
|
+
sheet.crop((col * cell, row * cell, (col + 1) * cell, (row + 1) * cell))
|
|
50
|
+
for row in range(rows)
|
|
51
|
+
for col in range(cols)
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def sheet_grid(path: Path, cell: int = CELL) -> tuple[int, int]:
|
|
56
|
+
"""The sheet's grid in cells."""
|
|
57
|
+
from PIL import Image
|
|
58
|
+
|
|
59
|
+
sheet = Image.open(path)
|
|
60
|
+
return sheet.width // cell, sheet.height // cell
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def pick_row(cells: list, cols: int, row: int, frames: int | None = None) -> list:
|
|
64
|
+
"""One row of the sheet — one animation, in order.
|
|
65
|
+
|
|
66
|
+
A sheet row is an animation; which row is which is the sheet's business, not this
|
|
67
|
+
module's, so the row comes in as a number.
|
|
68
|
+
"""
|
|
69
|
+
total_rows = len(cells) // cols
|
|
70
|
+
if not 0 <= row < total_rows:
|
|
71
|
+
raise ValueError(f"row {row} is outside a sheet of {total_rows} rows")
|
|
72
|
+
taken = cells[row * cols : (row + 1) * cols]
|
|
73
|
+
return taken[:frames] if frames else taken
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def scale_cells(cells: list, target: int, backdrop: tuple[int, int, int] = BACKDROP) -> list:
|
|
77
|
+
"""Blow each cell up to `target`, nearest neighbour, on a flat opaque field.
|
|
78
|
+
|
|
79
|
+
The factor is a whole number so every source pixel becomes an exact square. Scaling
|
|
80
|
+
to fill `target` precisely would need a fractional factor, which is the thing being
|
|
81
|
+
avoided, so the enlarged cell is centred on a `target` canvas instead.
|
|
82
|
+
"""
|
|
83
|
+
from PIL import Image
|
|
84
|
+
|
|
85
|
+
if not cells:
|
|
86
|
+
raise ValueError("no cells to scale")
|
|
87
|
+
|
|
88
|
+
cell = cells[0].width
|
|
89
|
+
factor = max(1, target // cell)
|
|
90
|
+
size = cell * factor
|
|
91
|
+
|
|
92
|
+
scaled = []
|
|
93
|
+
for source in cells:
|
|
94
|
+
big = source.resize((size, size), Image.NEAREST)
|
|
95
|
+
canvas = Image.new("RGB", (target, target), backdrop)
|
|
96
|
+
offset = (target - size) // 2
|
|
97
|
+
canvas.paste(big, (offset, offset), big)
|
|
98
|
+
scaled.append(canvas)
|
|
99
|
+
return scaled
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def repeat_to(frames: list, fps: int, seconds: float) -> list:
|
|
103
|
+
"""The cycle, repeated until it is `seconds` long.
|
|
104
|
+
|
|
105
|
+
Safe because it is a cycle: the sheet's last frame already meets its first, so the
|
|
106
|
+
seam is the same seam the animation has in the game.
|
|
107
|
+
"""
|
|
108
|
+
if not frames:
|
|
109
|
+
raise ValueError("no frames to repeat")
|
|
110
|
+
wanted = max(len(frames), round(fps * seconds))
|
|
111
|
+
return [frames[index % len(frames)] for index in range(wanted)]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def write_clip(frames: list, out: Path, fps: int = 12) -> Path:
|
|
115
|
+
"""Encode the frames as an mp4 the endpoint will take."""
|
|
116
|
+
import av
|
|
117
|
+
|
|
118
|
+
if not frames:
|
|
119
|
+
raise ValueError("no frames to encode")
|
|
120
|
+
|
|
121
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
width, height = frames[0].size
|
|
123
|
+
with av.open(str(out), mode="w") as container:
|
|
124
|
+
stream = container.add_stream("libx264", rate=fps)
|
|
125
|
+
stream.width, stream.height = width, height
|
|
126
|
+
stream.pix_fmt = "yuv420p"
|
|
127
|
+
# A high bitrate keeps the block edges the pose extractor reads. Measured against
|
|
128
|
+
# the frames going in, this loses a mean of 0.75 of 255 per channel, so what
|
|
129
|
+
# reaches the endpoint is the scaled cell and not the codec's idea of it.
|
|
130
|
+
stream.options = {"crf": "16", "preset": "veryslow", "tune": "stillimage"}
|
|
131
|
+
for frame in frames:
|
|
132
|
+
container.mux(stream.encode(av.VideoFrame.from_image(frame.convert("RGB"))))
|
|
133
|
+
container.mux(stream.encode())
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def build(
|
|
138
|
+
sheet: Path,
|
|
139
|
+
out: Path,
|
|
140
|
+
*,
|
|
141
|
+
row: int = 0,
|
|
142
|
+
frames: int | None = None,
|
|
143
|
+
cell: int = CELL,
|
|
144
|
+
target: int = 720,
|
|
145
|
+
fps: int = 12,
|
|
146
|
+
seconds: float = 4.0,
|
|
147
|
+
) -> dict:
|
|
148
|
+
"""A reference sheet through to a driving clip. Returns what it measured."""
|
|
149
|
+
cols, rows = sheet_grid(sheet, cell)
|
|
150
|
+
cells = pick_row(cells_from_sheet(sheet, cell), cols, row, frames)
|
|
151
|
+
scaled = scale_cells(cells, target)
|
|
152
|
+
filled = repeat_to(scaled, fps, seconds)
|
|
153
|
+
write_clip(filled, out, fps)
|
|
154
|
+
return {
|
|
155
|
+
"sheet": f"{cols}x{rows}",
|
|
156
|
+
"row": row,
|
|
157
|
+
"cycle": len(cells),
|
|
158
|
+
"frames": len(filled),
|
|
159
|
+
"size": f"{target}x{target}",
|
|
160
|
+
"factor": max(1, target // cell),
|
|
161
|
+
"fps": fps,
|
|
162
|
+
"seconds": round(len(filled) / fps, 2),
|
|
163
|
+
}
|
spritegen/endpoints.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""The motion-transfer endpoints, and what each one takes.
|
|
2
|
+
|
|
3
|
+
Three of them do the same job — a character image plus a driving clip, out comes the
|
|
4
|
+
character doing that movement — and each names its knobs differently. The table is here
|
|
5
|
+
rather than inside the stage so the CLI can check an option against the chosen endpoint
|
|
6
|
+
*before* spending, and so adding a fourth is one entry rather than a branch.
|
|
7
|
+
|
|
8
|
+
Every one takes `image_url` and `video_url`; `options` is only what is particular to it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Endpoint:
|
|
18
|
+
"""One motion-transfer endpoint.
|
|
19
|
+
|
|
20
|
+
`options` maps an option name to the values it accepts: `None` means anything of the
|
|
21
|
+
right type, a tuple means exactly those. `defaults` is what goes in the payload when
|
|
22
|
+
the caller said nothing, so a dry run shows the real request rather than a sketch.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
id: str
|
|
26
|
+
summary: str
|
|
27
|
+
options: dict[str, tuple | None] = field(default_factory=dict)
|
|
28
|
+
defaults: dict = field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
def accepts(self, name: str) -> bool:
|
|
31
|
+
return name in self.options
|
|
32
|
+
|
|
33
|
+
def check(self, name: str, value) -> None:
|
|
34
|
+
"""Raise if this endpoint would not take `name=value` — R4.4.
|
|
35
|
+
|
|
36
|
+
Every option goes through here, including the ones the stage declares as flags
|
|
37
|
+
of its own. An option is an option whether it arrived as `--set name=value` or
|
|
38
|
+
as `--prompt`, and sending one the endpoint never declared is a paid call that
|
|
39
|
+
should not have been made.
|
|
40
|
+
"""
|
|
41
|
+
if not self.accepts(name):
|
|
42
|
+
known = ", ".join(sorted(self.options)) or "no options of its own"
|
|
43
|
+
raise ValueError(f"{self.id} has no option {name!r}; it takes: {known}")
|
|
44
|
+
allowed = self.options[name]
|
|
45
|
+
if allowed is not None and value not in allowed:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"{self.id} takes {name}={' or '.join(map(str, allowed))}, not {value!r}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
MOTION: dict[str, Endpoint] = {
|
|
52
|
+
"wan-motion": Endpoint(
|
|
53
|
+
id="fal-ai/wan-motion",
|
|
54
|
+
summary="720p, retargets the driving skeleton to the character's proportions",
|
|
55
|
+
options={
|
|
56
|
+
"adapt_motion": (True, False),
|
|
57
|
+
"enhance_identity": (True, False),
|
|
58
|
+
"acceleration": ("none", "regular"),
|
|
59
|
+
"seed": None,
|
|
60
|
+
"enable_safety_checker": (True, False),
|
|
61
|
+
},
|
|
62
|
+
defaults={"adapt_motion": True, "acceleration": "regular"},
|
|
63
|
+
),
|
|
64
|
+
"wan-animate": Endpoint(
|
|
65
|
+
id="fal-ai/wan/v2.2-14b/animate/move",
|
|
66
|
+
summary="Wan 2.2 Animate, the full model wan-motion is a streamlined form of",
|
|
67
|
+
options={
|
|
68
|
+
"seed": None,
|
|
69
|
+
"resolution": ("480p", "580p", "720p"),
|
|
70
|
+
"num_inference_steps": None,
|
|
71
|
+
"enable_safety_checker": (True, False),
|
|
72
|
+
},
|
|
73
|
+
defaults={"resolution": "720p"},
|
|
74
|
+
),
|
|
75
|
+
"one-to-all": Endpoint(
|
|
76
|
+
id="fal-ai/one-to-all-animation/14b",
|
|
77
|
+
summary="pose-driven, with identity and pose weighted against each other",
|
|
78
|
+
options={
|
|
79
|
+
"prompt": None,
|
|
80
|
+
"negative_prompt": None,
|
|
81
|
+
"resolution": ("480p", "580p", "720p"),
|
|
82
|
+
"num_inference_steps": None,
|
|
83
|
+
"image_guidance_scale": None,
|
|
84
|
+
"pose_guidance_scale": None,
|
|
85
|
+
},
|
|
86
|
+
# prompt and negative_prompt are required by this one's schema, so they are
|
|
87
|
+
# here as empty strings rather than left out: absent, the call comes back 422
|
|
88
|
+
# and the driving clip was uploaded for nothing.
|
|
89
|
+
defaults={
|
|
90
|
+
"prompt": "",
|
|
91
|
+
"negative_prompt": "",
|
|
92
|
+
"resolution": "720p",
|
|
93
|
+
"num_inference_steps": 30,
|
|
94
|
+
"image_guidance_scale": 2,
|
|
95
|
+
"pose_guidance_scale": 1.5,
|
|
96
|
+
},
|
|
97
|
+
),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#: The default. Fewest knobs, and the one whose pose retargeting is the point: the
|
|
101
|
+
#: driving sheet and the generated character rarely have the same proportions. The other
|
|
102
|
+
#: two are here because they fail differently — `one-to-all` is the one to reach for when
|
|
103
|
+
#: identity and pose need weighing against each other, which is a dial the other two do
|
|
104
|
+
#: not expose.
|
|
105
|
+
DEFAULT = "wan-motion"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get(name: str) -> Endpoint:
|
|
109
|
+
try:
|
|
110
|
+
return MOTION[name]
|
|
111
|
+
except KeyError:
|
|
112
|
+
known = ", ".join(sorted(MOTION))
|
|
113
|
+
raise KeyError(f"unknown motion endpoint {name!r}; there are: {known}") from None
|