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/fal.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""The one place a command turns into money.
|
|
2
|
+
|
|
3
|
+
Every paid call goes through `call`, which does two things before anything is spent:
|
|
4
|
+
it refuses without a key, and it honours a dry run by printing the payload instead of
|
|
5
|
+
sending it. Both live here rather than in each stage so no stage can forget one.
|
|
6
|
+
|
|
7
|
+
`_subscribe` is the seam. It is the only function that touches `fal_client`, which is
|
|
8
|
+
imported inside it: the rest of the CLI — `--help`, `status`, `cost` — must not pay the
|
|
9
|
+
import cost of an HTTP client, and a test must not need one.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from urllib.parse import urlparse
|
|
17
|
+
|
|
18
|
+
KEY_VAR = "FAL_KEY"
|
|
19
|
+
|
|
20
|
+
#: Where a result may be fetched from. The endpoint decides what URL comes back, and
|
|
21
|
+
#: `urls_in` walks the whole response for anything under a `url` key — so without this,
|
|
22
|
+
#: a malformed or tampered response naming `file:///…` would have a local file copied
|
|
23
|
+
#: into the asset and passed off as generated art.
|
|
24
|
+
ALLOWED_HOSTS = ("fal.media", "fal.ai", "fal.run", "fal-cdn.com")
|
|
25
|
+
|
|
26
|
+
#: A hard ceiling on a downloaded body. A clip is megabytes; nothing here is hundreds.
|
|
27
|
+
MAX_BYTES = 256 * 1024 * 1024
|
|
28
|
+
|
|
29
|
+
TIMEOUT = 600
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Untrusted(ValueError):
|
|
33
|
+
"""A URL the endpoint returned that this tool will not fetch."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _allowed_names() -> tuple[str, ...]:
|
|
37
|
+
from . import settings
|
|
38
|
+
|
|
39
|
+
return ALLOWED_HOSTS + settings.load().extra_hosts()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _bare_host(url) -> str:
|
|
43
|
+
"""The hostname httpx will connect to, lowered, with no port and no brackets."""
|
|
44
|
+
return (url.host or "").lower()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def check_url(url: str, allowed: tuple[str, ...] | None = None) -> str:
|
|
48
|
+
r"""The URL, if it is one this tool will fetch — R2.8.
|
|
49
|
+
|
|
50
|
+
`allowed` is the host set to hold it to; without one it is fal's, extended by
|
|
51
|
+
`SPRITEGEN_ALLOWED_HOSTS`. It is a parameter because this is not the only place that
|
|
52
|
+
writes bytes from the network to disk — the weight cache does too, from a different
|
|
53
|
+
set of hosts, and it needs every one of the checks here rather than its own worse
|
|
54
|
+
copy of two of them.
|
|
55
|
+
|
|
56
|
+
The host is read from `httpx.URL`, which is the parser that opens the connection,
|
|
57
|
+
and it is required to agree with `urlparse` besides. **Any authority carrying
|
|
58
|
+
userinfo is refused outright**, and that is what catches the crafted case: fal's
|
|
59
|
+
links never need userinfo, and a URL like `https://evil.com\@fal.media/x` puts the
|
|
60
|
+
part a reader's eye stops at into the userinfo instead of the host.
|
|
61
|
+
"""
|
|
62
|
+
import httpx
|
|
63
|
+
|
|
64
|
+
parsed = urlparse(url)
|
|
65
|
+
if parsed.scheme != "https":
|
|
66
|
+
raise Untrusted(f"refusing to fetch {url!r}: only https")
|
|
67
|
+
|
|
68
|
+
connects = httpx.URL(url)
|
|
69
|
+
if connects.userinfo or "@" in (parsed.netloc or ""):
|
|
70
|
+
raise Untrusted(f"refusing to fetch {url!r}: the authority carries userinfo")
|
|
71
|
+
|
|
72
|
+
host = _bare_host(connects)
|
|
73
|
+
if host != (parsed.hostname or "").lower():
|
|
74
|
+
raise Untrusted(
|
|
75
|
+
f"refusing to fetch {url!r}: it parses as {parsed.hostname!r} but connects "
|
|
76
|
+
f"to {host!r}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
extensible = allowed is None
|
|
80
|
+
allowed = _allowed_names() if extensible else allowed
|
|
81
|
+
if not any(host == name or host.endswith(f".{name}") for name in allowed):
|
|
82
|
+
hint = " Set SPRITEGEN_ALLOWED_HOSTS to add one." if extensible else ""
|
|
83
|
+
raise Untrusted(f"refusing to fetch {url!r}: only {', '.join(allowed)}.{hint}")
|
|
84
|
+
return url
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
#: How many redirects a fetch will follow before giving up.
|
|
88
|
+
MAX_HOPS = 5
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def upload(path: Path) -> str:
|
|
92
|
+
"""Put a local file in fal's storage and return the URL that reaches it.
|
|
93
|
+
|
|
94
|
+
That URL is standing access to the file for anyone holding it, which is why
|
|
95
|
+
`ledger.redact` keeps it out of the recorded payload.
|
|
96
|
+
"""
|
|
97
|
+
import fal_client
|
|
98
|
+
|
|
99
|
+
return fal_client.upload_file(str(path))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def download(url: str, dest: Path, allowed: tuple[str, ...] | None = None) -> Path:
|
|
103
|
+
"""Fetch into `dest`, refusing anything this tool should not fetch.
|
|
104
|
+
|
|
105
|
+
**The redirect chain is walked here rather than inside the client.** Following it
|
|
106
|
+
automatically means a live request has already gone to every intermediate host by the
|
|
107
|
+
time the call returns, so checking where it landed checks nothing about the middle.
|
|
108
|
+
`follow_redirects=False` plus `response.next_request` makes each hop a decision this
|
|
109
|
+
function gets to refuse before it happens.
|
|
110
|
+
|
|
111
|
+
The body streams in chunks against a ceiling rather than being read whole, so an
|
|
112
|
+
oversized response fails instead of being held in memory — and the partial file is
|
|
113
|
+
removed, because a truncated image that `exists()` accepts is worse than none.
|
|
114
|
+
|
|
115
|
+
`allowed` narrows the host set for a caller that is not fetching a fal result — the
|
|
116
|
+
weight cache passes its own. Nothing else about the fetch differs, which is why it is
|
|
117
|
+
this function with a parameter and not a second one.
|
|
118
|
+
"""
|
|
119
|
+
import httpx
|
|
120
|
+
|
|
121
|
+
check_url(url, allowed)
|
|
122
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
|
|
124
|
+
with httpx.Client(follow_redirects=False, timeout=TIMEOUT) as client:
|
|
125
|
+
request = client.build_request("GET", url)
|
|
126
|
+
for _ in range(MAX_HOPS + 1):
|
|
127
|
+
with client.send(request, stream=True) as response:
|
|
128
|
+
hop = response.next_request
|
|
129
|
+
if hop is not None:
|
|
130
|
+
check_url(str(hop.url), allowed)
|
|
131
|
+
request = hop
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
response.raise_for_status()
|
|
135
|
+
size = 0
|
|
136
|
+
try:
|
|
137
|
+
with dest.open("wb") as handle:
|
|
138
|
+
for chunk in response.iter_bytes(1 << 20):
|
|
139
|
+
size += len(chunk)
|
|
140
|
+
if size > MAX_BYTES:
|
|
141
|
+
raise Untrusted(
|
|
142
|
+
f"{url} is over the {MAX_BYTES:,} byte ceiling"
|
|
143
|
+
)
|
|
144
|
+
handle.write(chunk)
|
|
145
|
+
except BaseException:
|
|
146
|
+
dest.unlink(missing_ok=True)
|
|
147
|
+
raise
|
|
148
|
+
return dest
|
|
149
|
+
|
|
150
|
+
raise Untrusted(f"{url} redirected more than {MAX_HOPS} times")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class MissingKey(ValueError):
|
|
154
|
+
"""No usable `FAL_KEY`.
|
|
155
|
+
|
|
156
|
+
A ValueError so the CLI turns it into an exit code and a line on stderr: an absent
|
|
157
|
+
key is a thing to go fix, not a bug to report.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def require_key() -> str:
|
|
162
|
+
"""The key, or an error naming what to set — R2.2.
|
|
163
|
+
|
|
164
|
+
Blank counts as absent. An empty environment variable is how a key goes missing in
|
|
165
|
+
practice, and letting it through only moves the failure to the endpoint, after the
|
|
166
|
+
request was built.
|
|
167
|
+
"""
|
|
168
|
+
from . import settings
|
|
169
|
+
|
|
170
|
+
key = settings.load().fal_key or ""
|
|
171
|
+
if not key.strip():
|
|
172
|
+
raise MissingKey(f"{KEY_VAR} is not set; a paid call needs it")
|
|
173
|
+
return key
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def show(endpoint: str, payload: dict) -> None:
|
|
177
|
+
"""Print the call that would be made — R2.3.
|
|
178
|
+
|
|
179
|
+
The payload is printed as it would be sent, not summarised: the point of a dry run
|
|
180
|
+
is to check the thing itself, and a summary is where the difference hides.
|
|
181
|
+
"""
|
|
182
|
+
print(f"dry run: {endpoint}")
|
|
183
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=True))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _subscribe(endpoint: str, payload: dict) -> dict:
|
|
187
|
+
"""The call itself. Patched out in tests; nothing else imports `fal_client`."""
|
|
188
|
+
import fal_client
|
|
189
|
+
|
|
190
|
+
return fal_client.subscribe(endpoint, arguments=payload, with_logs=False)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def call(endpoint: str, payload: dict, *, dry_run: bool = False) -> dict:
|
|
194
|
+
"""Run one paid call, or show what it would be — R2.2, R2.3.
|
|
195
|
+
|
|
196
|
+
A dry run returns before the key is checked, on purpose: it issues no paid call, so
|
|
197
|
+
a payload can be inspected on a machine that has no credentials at all.
|
|
198
|
+
"""
|
|
199
|
+
if dry_run:
|
|
200
|
+
show(endpoint, payload)
|
|
201
|
+
return {"dry_run": True, "endpoint": endpoint, "payload": payload}
|
|
202
|
+
|
|
203
|
+
require_key()
|
|
204
|
+
return _subscribe(endpoint, payload)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def urls_in(result: dict) -> list[str]:
|
|
208
|
+
"""Every URL an endpoint returned, in the order found.
|
|
209
|
+
|
|
210
|
+
The endpoints disagree about where they put it — `video.url`, `images[].url`,
|
|
211
|
+
`url` — so this walks the result instead of encoding one shape per endpoint and
|
|
212
|
+
getting it wrong for the next one.
|
|
213
|
+
"""
|
|
214
|
+
found: list[str] = []
|
|
215
|
+
|
|
216
|
+
def walk(node) -> None:
|
|
217
|
+
if isinstance(node, dict):
|
|
218
|
+
for key, value in node.items():
|
|
219
|
+
if key == "url" and isinstance(value, str):
|
|
220
|
+
found.append(value)
|
|
221
|
+
else:
|
|
222
|
+
walk(value)
|
|
223
|
+
elif isinstance(node, list):
|
|
224
|
+
for item in node:
|
|
225
|
+
walk(item)
|
|
226
|
+
|
|
227
|
+
walk(result)
|
|
228
|
+
return found
|
spritegen/imaging.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""The local, unpaid transforms — chroma cutting and pixel-grid recovery.
|
|
2
|
+
|
|
3
|
+
Ported from the `gen_image.py` this grew out of, and pinned by `tests/parity`. Nothing here
|
|
4
|
+
calls an endpoint: given the same bytes in, these produce the same bytes out, which is
|
|
5
|
+
why they can be re-run for free after a paid call and why the fixtures can hold them to
|
|
6
|
+
the behaviour the scripts had.
|
|
7
|
+
|
|
8
|
+
Two stages use them, which is the reason they are not inside either: `anchor` cuts the
|
|
9
|
+
chroma off what it generated, and `board` recovers the grid across a whole board.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_chroma(text: str) -> tuple[int, int, int]:
|
|
18
|
+
"""`00b140` to `(0, 177, 64)`."""
|
|
19
|
+
raw = text.strip().lstrip("#")
|
|
20
|
+
if len(raw) != 6:
|
|
21
|
+
raise ValueError("a chroma key is 6 hex digits, e.g. 00b140")
|
|
22
|
+
try:
|
|
23
|
+
value = int(raw, 16)
|
|
24
|
+
except ValueError:
|
|
25
|
+
raise ValueError(f"chroma key {text!r} is not hex") from None
|
|
26
|
+
return (value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _near_transparent(alpha, radius: int):
|
|
30
|
+
"""Opaque pixels within `radius` of a transparent one."""
|
|
31
|
+
import numpy as np
|
|
32
|
+
|
|
33
|
+
near = alpha < 1.0
|
|
34
|
+
grown = near.copy()
|
|
35
|
+
for _ in range(radius):
|
|
36
|
+
padded = np.pad(grown, 1, constant_values=False)
|
|
37
|
+
grown = padded[:-2, 1:-1] | padded[2:, 1:-1] | padded[1:-1, :-2] | padded[1:-1, 2:] | grown
|
|
38
|
+
return grown & (alpha > 0.0)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cut_chroma(
|
|
42
|
+
path: Path,
|
|
43
|
+
key: tuple[int, int, int],
|
|
44
|
+
tol: float,
|
|
45
|
+
feather: float,
|
|
46
|
+
despill: bool,
|
|
47
|
+
despill_radius: int,
|
|
48
|
+
mode: str = "flood",
|
|
49
|
+
) -> dict:
|
|
50
|
+
"""Replace a chroma background with alpha, in place, and report what it measured.
|
|
51
|
+
|
|
52
|
+
`flood` counts as background only what the border can reach, so a green the artist
|
|
53
|
+
put inside the figure survives. `global` takes every pixel near the key wherever it
|
|
54
|
+
is, which is the only thing that clears islanded chroma — the gap between an arm
|
|
55
|
+
and the body, the space through hair — at the cost of eating art that is
|
|
56
|
+
legitimately that colour.
|
|
57
|
+
"""
|
|
58
|
+
import numpy as np
|
|
59
|
+
from PIL import Image, ImageDraw
|
|
60
|
+
|
|
61
|
+
rgb = np.asarray(Image.open(path).convert("RGB"), dtype=np.float32)
|
|
62
|
+
height, width = rgb.shape[:2]
|
|
63
|
+
|
|
64
|
+
distance = np.sqrt(((rgb - np.array(key, dtype=np.float32)) ** 2).sum(axis=2))
|
|
65
|
+
|
|
66
|
+
# Background candidate: near the key, with the feather band still inside.
|
|
67
|
+
candidate = distance <= (tol + feather)
|
|
68
|
+
|
|
69
|
+
if mode == "global":
|
|
70
|
+
reached = candidate
|
|
71
|
+
else:
|
|
72
|
+
# A one-pixel frame that is guaranteed to be a candidate gives the flood a
|
|
73
|
+
# single starting point, whatever the art does at the edge.
|
|
74
|
+
flood = Image.new("L", (width + 2, height + 2), 255)
|
|
75
|
+
flood.paste(Image.fromarray(np.where(candidate, 255, 0).astype(np.uint8)), (1, 1))
|
|
76
|
+
ImageDraw.floodfill(flood, (0, 0), 128, thresh=0)
|
|
77
|
+
reached = np.asarray(flood, dtype=np.uint8)[1 : height + 1, 1 : width + 1] == 128
|
|
78
|
+
|
|
79
|
+
# Inside the reached background, alpha ramps 0 to 1 across the feather band.
|
|
80
|
+
if feather > 0:
|
|
81
|
+
ramp = np.clip((distance - tol) / feather, 0.0, 1.0)
|
|
82
|
+
else:
|
|
83
|
+
ramp = (distance > tol).astype(np.float32)
|
|
84
|
+
alpha = np.where(reached, ramp, 1.0)
|
|
85
|
+
|
|
86
|
+
if despill and despill_radius > 0:
|
|
87
|
+
# Leaked key lives in two places: along the contour, where the chroma painted
|
|
88
|
+
# over the edge, and islanded inside the silhouette, where the border flood
|
|
89
|
+
# never arrives and the pixel stays opaque and green. Both go, because without
|
|
90
|
+
# the second a downscale spreads each island over a whole block.
|
|
91
|
+
dominant = int(np.argmax(key))
|
|
92
|
+
others = [channel for channel in range(3) if channel != dominant]
|
|
93
|
+
ceiling = np.maximum(rgb[:, :, others[0]], rgb[:, :, others[1]])
|
|
94
|
+
target = _near_transparent(alpha, despill_radius) | (~reached & candidate)
|
|
95
|
+
spilled = target & (alpha > 0.0) & (rgb[:, :, dominant] > ceiling)
|
|
96
|
+
rgb[:, :, dominant] = np.where(spilled, ceiling, rgb[:, :, dominant])
|
|
97
|
+
spill_px = int(spilled.sum())
|
|
98
|
+
else:
|
|
99
|
+
spill_px = 0
|
|
100
|
+
|
|
101
|
+
out = np.dstack([rgb, alpha * 255.0]).round().clip(0, 255).astype(np.uint8)
|
|
102
|
+
Image.fromarray(out, mode="RGBA").save(path)
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
"transparent_px": int((alpha <= 0.0).sum()),
|
|
106
|
+
"partial_px": int(((alpha > 0.0) & (alpha < 1.0)).sum()),
|
|
107
|
+
"opaque_px": int((alpha >= 1.0).sum()),
|
|
108
|
+
"despilled_px": spill_px,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
#: Seeds OpenCV's RNG before pixelfixer reconstructs. See `to_pixelart`.
|
|
113
|
+
PIXELART_SEED = 0
|
|
114
|
+
|
|
115
|
+
INSTALL_PIXELFIXER = (
|
|
116
|
+
"recovering a native pixel grid needs pixelfixer, which is on no package registry: "
|
|
117
|
+
'pip install "pixelfixer @ '
|
|
118
|
+
'git+https://github.com/Retro-Diffusion/pixel-art-fixer.git#subdirectory=python"'
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def require_pixelfixer():
|
|
123
|
+
"""`pixelfixer`, or an error naming the one command that fixes it.
|
|
124
|
+
|
|
125
|
+
Not a declared dependency, and it cannot be one: it is on no registry, and a direct
|
|
126
|
+
URL in `dependencies` or an extra makes a distribution PyPI refuses — so declaring it
|
|
127
|
+
would trade an uninstallable package for an unpublishable one.
|
|
128
|
+
|
|
129
|
+
It reaches exactly two commands, `anchor --pixelart` and `board --art pixel-art`, so
|
|
130
|
+
the tool without it is the whole tool minus one art direction. That is the same shape
|
|
131
|
+
`torch` and `rembg` have, and the third time this project has met it.
|
|
132
|
+
"""
|
|
133
|
+
try:
|
|
134
|
+
import pixelfixer
|
|
135
|
+
except ModuleNotFoundError as exc:
|
|
136
|
+
raise NotImplementedError(INSTALL_PIXELFIXER) from exc
|
|
137
|
+
return pixelfixer
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def pixelfixer_available() -> bool:
|
|
141
|
+
"""Whether the grid recovery could run. Used to report, never to decide silently."""
|
|
142
|
+
try:
|
|
143
|
+
require_pixelfixer()
|
|
144
|
+
except NotImplementedError:
|
|
145
|
+
return False
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def to_pixelart(path: Path, colors: int, scale: int, seed: int = PIXELART_SEED) -> dict:
|
|
150
|
+
"""Recover the native pixel grid the art already has and rewrite the image on it.
|
|
151
|
+
|
|
152
|
+
The detector votes on a cell from the art itself. It does not invent one: on a
|
|
153
|
+
smooth painting with no blocks in it the vote returns nonsense, which is why the
|
|
154
|
+
prompt has to ask for flat blocks and why `cols` and `rows` come back in the report
|
|
155
|
+
— they are how you check a grid was there at all.
|
|
156
|
+
|
|
157
|
+
**Seeded, unlike the script this came from.** pixelfixer reconstructs each block by
|
|
158
|
+
k-means on OpenCV's global RNG, so the original ran three times on one input and
|
|
159
|
+
produced three different images — same grid, same colour count, different pixels.
|
|
160
|
+
Seeding costs nothing and is what makes a re-run comparable to the run before it,
|
|
161
|
+
and a paid call's output worth re-deriving instead of re-buying.
|
|
162
|
+
"""
|
|
163
|
+
require_pixelfixer()
|
|
164
|
+
|
|
165
|
+
import cv2
|
|
166
|
+
import numpy as np
|
|
167
|
+
from PIL import Image
|
|
168
|
+
from pixelfixer import detect
|
|
169
|
+
from pixelfixer.reconstruct import reconstruct
|
|
170
|
+
|
|
171
|
+
cv2.setRNGSeed(seed)
|
|
172
|
+
rgba = np.array(Image.open(path).convert("RGBA"))
|
|
173
|
+
found = detect(rgba)
|
|
174
|
+
out = reconstruct(rgba, found["step_x"], found["step_y"], found["cols"], found["rows"])
|
|
175
|
+
image = Image.fromarray(out)
|
|
176
|
+
|
|
177
|
+
if colors:
|
|
178
|
+
# No dither: it scatters half-tone noise across blocks the grid just flattened.
|
|
179
|
+
quantized = image.convert("RGB").quantize(colors=colors, dither=Image.Dither.NONE)
|
|
180
|
+
merged = quantized.convert("RGBA")
|
|
181
|
+
merged.putalpha(image.getchannel("A"))
|
|
182
|
+
image = merged
|
|
183
|
+
|
|
184
|
+
native = image.size
|
|
185
|
+
if scale > 1:
|
|
186
|
+
image = image.resize((native[0] * scale, native[1] * scale), Image.NEAREST)
|
|
187
|
+
|
|
188
|
+
image.save(path)
|
|
189
|
+
used = len({tuple(pixel) for pixel in np.array(image.convert("RGBA")).reshape(-1, 4)})
|
|
190
|
+
return {
|
|
191
|
+
"cols": int(found["cols"]),
|
|
192
|
+
"rows": int(found["rows"]),
|
|
193
|
+
"step_x": round(float(found["step_x"]), 3),
|
|
194
|
+
"step_y": round(float(found["step_y"]), 3),
|
|
195
|
+
"native": f"{native[0]}x{native[1]}",
|
|
196
|
+
"scale": scale,
|
|
197
|
+
"seed": seed,
|
|
198
|
+
"colours": used,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def measure_alpha(path: Path) -> dict:
|
|
203
|
+
"""What the alpha channel of a cut image looks like.
|
|
204
|
+
|
|
205
|
+
Ported from `bg_remove.py`'s `measure`. It is how you tell a matte that worked from
|
|
206
|
+
one that returned the picture untouched: an image with no transparent pixels came
|
|
207
|
+
back opaque, whatever the endpoint reported.
|
|
208
|
+
"""
|
|
209
|
+
import numpy as np
|
|
210
|
+
from PIL import Image
|
|
211
|
+
|
|
212
|
+
image = Image.open(path).convert("RGBA")
|
|
213
|
+
alpha = np.asarray(image)[:, :, 3]
|
|
214
|
+
return {
|
|
215
|
+
"size": "x".join(str(value) for value in image.size),
|
|
216
|
+
"transparent_px": int((alpha == 0).sum()),
|
|
217
|
+
"partial_px": int(((alpha > 0) & (alpha < 255)).sum()),
|
|
218
|
+
"opaque_px": int((alpha == 255).sum()),
|
|
219
|
+
}
|
spritegen/ledger.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""One line per paid call, appended, never rewritten.
|
|
2
|
+
|
|
3
|
+
This is the only record that a call happened and what it cost in artifacts. It is
|
|
4
|
+
append-only JSONL rather than a document for one reason: a run that dies halfway has
|
|
5
|
+
still spent the money, and a format that has to be re-serialised whole can lose the
|
|
6
|
+
line that says so.
|
|
7
|
+
|
|
8
|
+
What goes in a line is fixed by R2.1 — the endpoint, the exact payload sent, the URLs
|
|
9
|
+
that came back, and the sha256 of every file written. That is enough to answer "was
|
|
10
|
+
this file paid for, and by which call" months later, and enough for `cost` to report
|
|
11
|
+
an asset whose outputs were long since deleted.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
CHUNK = 1 << 20
|
|
22
|
+
|
|
23
|
+
#: What an uploaded file's URL is replaced by. `fal_client.upload_file` returns standing
|
|
24
|
+
#: access to that file for anyone holding the link, so recording it verbatim would turn
|
|
25
|
+
#: this ledger into a key to every reference image, anchor and clip ever sent — art that
|
|
26
|
+
#: has not shipped included. What the record needs is that a file went up and which one,
|
|
27
|
+
#: not a way back to it.
|
|
28
|
+
REDACTED = "<uploaded>"
|
|
29
|
+
|
|
30
|
+
#: Payload keys that carry one, or a list of them.
|
|
31
|
+
UPLOAD_KEYS = frozenset(
|
|
32
|
+
{
|
|
33
|
+
"image_url",
|
|
34
|
+
"image_urls",
|
|
35
|
+
"video_url",
|
|
36
|
+
"reference_image_urls",
|
|
37
|
+
"end_image_url",
|
|
38
|
+
"tail_image_url",
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def redact(payload: dict) -> dict:
|
|
44
|
+
"""The payload as it is recorded — everything, minus the upload URLs.
|
|
45
|
+
|
|
46
|
+
R2.1 asks for the exact payload sent, and this is the one deliberate exception:
|
|
47
|
+
every other field is what was asked for, and these are handles to files that are
|
|
48
|
+
already on this disk anyway.
|
|
49
|
+
"""
|
|
50
|
+
out = {}
|
|
51
|
+
for key, value in payload.items():
|
|
52
|
+
if key not in UPLOAD_KEYS:
|
|
53
|
+
out[key] = value
|
|
54
|
+
elif isinstance(value, list):
|
|
55
|
+
out[key] = [REDACTED] * len(value)
|
|
56
|
+
else:
|
|
57
|
+
out[key] = REDACTED
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def sha256_of(path: Path) -> str:
|
|
62
|
+
"""The file's digest, read in chunks — a board is tens of megabytes."""
|
|
63
|
+
digest = hashlib.sha256()
|
|
64
|
+
with path.open("rb") as handle:
|
|
65
|
+
while chunk := handle.read(CHUNK):
|
|
66
|
+
digest.update(chunk)
|
|
67
|
+
return digest.hexdigest()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def describe(path: Path) -> dict:
|
|
71
|
+
"""One written file, as it appears in a ledger line."""
|
|
72
|
+
return {"path": path.name, "bytes": path.stat().st_size, "sha256": sha256_of(path)}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def entry(
|
|
76
|
+
*,
|
|
77
|
+
stage: str,
|
|
78
|
+
endpoint: str,
|
|
79
|
+
payload: dict,
|
|
80
|
+
urls: list[str] | None = None,
|
|
81
|
+
files: list[Path] | None = None,
|
|
82
|
+
at: str | None = None,
|
|
83
|
+
) -> dict:
|
|
84
|
+
"""A ledger line, built but not written.
|
|
85
|
+
|
|
86
|
+
Separate from `append` so a dry run can show exactly what would be recorded, and so
|
|
87
|
+
a test does not need a filesystem to check the shape.
|
|
88
|
+
"""
|
|
89
|
+
return {
|
|
90
|
+
"at": at or datetime.now(UTC).isoformat(timespec="seconds"),
|
|
91
|
+
"stage": stage,
|
|
92
|
+
"endpoint": endpoint,
|
|
93
|
+
"payload": redact(payload),
|
|
94
|
+
"urls": list(urls or []),
|
|
95
|
+
"files": [describe(path) for path in files or []],
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def append(path: Path, line: dict) -> dict:
|
|
100
|
+
"""Append one line, creating the ledger if this is the asset's first paid call."""
|
|
101
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
103
|
+
handle.write(json.dumps(line, ensure_ascii=False) + "\n")
|
|
104
|
+
return line
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def read(path: Path) -> list[dict]:
|
|
108
|
+
"""Every line, in order.
|
|
109
|
+
|
|
110
|
+
A blank line is skipped and a malformed one is fatal: the ledger is the record of
|
|
111
|
+
spending, and quietly dropping a line it could not parse would understate it.
|
|
112
|
+
"""
|
|
113
|
+
if not path.exists():
|
|
114
|
+
return []
|
|
115
|
+
lines = []
|
|
116
|
+
for number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
|
117
|
+
if not raw.strip():
|
|
118
|
+
continue
|
|
119
|
+
try:
|
|
120
|
+
lines.append(json.loads(raw))
|
|
121
|
+
except json.JSONDecodeError as exc:
|
|
122
|
+
raise ValueError(f"{path}:{number} is not a ledger line: {exc}") from exc
|
|
123
|
+
return lines
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def summarise(lines: list[dict]) -> list[dict]:
|
|
127
|
+
"""The ledger folded to one row per stage and endpoint, in first-call order — R2.4.
|
|
128
|
+
|
|
129
|
+
Grouped by both because a stage can be re-run against a different endpoint — that
|
|
130
|
+
is the whole point of having three motion endpoints — and a row that merged them
|
|
131
|
+
would hide which one the money went to.
|
|
132
|
+
"""
|
|
133
|
+
rows: dict[tuple[str, str], dict] = {}
|
|
134
|
+
for line in lines:
|
|
135
|
+
key = (line.get("stage", "?"), line.get("endpoint", "?"))
|
|
136
|
+
row = rows.setdefault(
|
|
137
|
+
key, {"stage": key[0], "endpoint": key[1], "calls": 0, "files": 0, "bytes": 0}
|
|
138
|
+
)
|
|
139
|
+
row["calls"] += 1
|
|
140
|
+
for written in line.get("files", []):
|
|
141
|
+
row["files"] += 1
|
|
142
|
+
row["bytes"] += int(written.get("bytes", 0))
|
|
143
|
+
return list(rows.values())
|