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/matting.py ADDED
@@ -0,0 +1,136 @@
1
+ """Cutting the background locally, on the GPU, instead of paying for it.
2
+
3
+ The paid matte is a segmentation model behind an endpoint. So is this one: `rembg` runs
4
+ BiRefNet — the same family the endpoint's `Matting` variant is — through onnxruntime, and
5
+ on a machine with CUDA the difference is seconds and no invoice. On a machine without
6
+ one it still runs, slower, on the CPU.
7
+
8
+ **This is not the chroma cut.** `imaging.cut_chroma` removes a colour the prompt asked
9
+ for; that is exact where the prompt was obeyed and useless where it was not. This asks a
10
+ model what the subject is, which is what hair, a blade edge and the gap under an arm
11
+ need — and it works on art that was never generated against a key field at all.
12
+
13
+ `rembg` is an optional extra, for the reason `torch` is: it brings onnxruntime with it,
14
+ and which build works depends on the machine's CUDA. Every other command keeps working
15
+ without it, and the refusal names the install line.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+
22
+ #: The default weights. BiRefNet general is the same family as the paid endpoint's
23
+ #: `Matting` variant, and gives continuous alpha rather than a hard mask — which is what
24
+ #: hair and a blade edge need.
25
+ DEFAULT_MODEL = "birefnet-general"
26
+
27
+ INSTALL = (
28
+ "the local matte needs rembg, which is an optional extra: "
29
+ 'pip install "spritegen-cli[matte]" (and "rembg[gpu]" for CUDA)'
30
+ )
31
+
32
+
33
+ def require_rembg():
34
+ """`rembg`, or an error naming the one command that fixes it."""
35
+ try:
36
+ import rembg
37
+ except ModuleNotFoundError as exc:
38
+ raise NotImplementedError(INSTALL) from exc
39
+ return rembg
40
+
41
+
42
+ def available() -> bool:
43
+ """Whether the local matte could run. Used to report, never to decide silently."""
44
+ try:
45
+ require_rembg()
46
+ except NotImplementedError:
47
+ return False
48
+ return True
49
+
50
+
51
+ def providers() -> list[str]:
52
+ """What onnxruntime says it can run on, in its own order of preference.
53
+
54
+ Reported by the command rather than assumed: a `rembg` installed without the GPU
55
+ build runs on the CPU and looks like a hang on a board of six frames.
56
+ """
57
+ try:
58
+ import onnxruntime
59
+ except ModuleNotFoundError:
60
+ return []
61
+ return list(onnxruntime.get_available_providers())
62
+
63
+
64
+ def on_gpu() -> bool:
65
+ return any(name.startswith(("CUDA", "ROCM")) for name in providers())
66
+
67
+
68
+ def known_models() -> tuple[str, ...]:
69
+ """The model names this rembg has, or empty if it will not say."""
70
+ require_rembg()
71
+ try:
72
+ from rembg.sessions import sessions_names
73
+ except ImportError:
74
+ return ()
75
+ return tuple(sessions_names)
76
+
77
+
78
+ def check_model(model: str) -> str:
79
+ """`model` if this rembg has it, and an error naming what it does have if not.
80
+
81
+ Checked here rather than left to `new_session`. The name can arrive from a `.env`
82
+ two directories up as easily as from the command line, it is an unconstrained string
83
+ either way, and what a third-party lookup does with a name it does not recognise is
84
+ not this tool's decision to inherit. An installed rembg that will not enumerate its
85
+ models gets the benefit of the doubt rather than a refusal it cannot satisfy.
86
+ """
87
+ known = known_models()
88
+ if known and model not in known:
89
+ raise ValueError(f"unknown matte model {model!r}; this rembg has: {', '.join(known)}")
90
+ return model
91
+
92
+
93
+ def session(model: str | None = None):
94
+ """One rembg session, reused across every frame of a board.
95
+
96
+ Built once and passed in, because loading the weights is most of the cost: matting
97
+ six frames with six sessions pays for the model six times.
98
+ """
99
+ from . import settings
100
+
101
+ rembg = require_rembg()
102
+ return rembg.new_session(check_model(model or settings.load().matte_model or DEFAULT_MODEL))
103
+
104
+
105
+ def cut(image, *, current=None, refine: bool = True, model: str | None = None):
106
+ """One image with its background gone. Returns RGBA.
107
+
108
+ `refine` is the edge decontamination, and it is what kills the coloured halo — the
109
+ same decision the paid stage makes with `--no-refine`, named the same way so the two
110
+ backends do not need two vocabularies. Turn it off when the figure is losing its own
111
+ outline rather than gaining a fringe.
112
+ """
113
+ rembg = require_rembg()
114
+ return rembg.remove(
115
+ image.convert("RGBA"),
116
+ session=current if current is not None else session(model),
117
+ post_process_mask=True,
118
+ decontaminate=refine,
119
+ )
120
+
121
+
122
+ def cut_file(source: Path, target: Path, *, current=None, refine: bool = True) -> dict:
123
+ """Cut one file to another, and report what the alpha came out like.
124
+
125
+ The measurement is `imaging.measure_alpha`, the same one the paid stage reports, and
126
+ it is how a matte that worked is told from one that handed back the picture untouched:
127
+ an image with no transparent pixel in it did not get cut, whatever anything claimed.
128
+ """
129
+ from PIL import Image
130
+
131
+ from .imaging import measure_alpha
132
+
133
+ result = cut(Image.open(source), current=current, refine=refine)
134
+ target.parent.mkdir(parents=True, exist_ok=True)
135
+ result.save(target)
136
+ return measure_alpha(target)
spritegen/migrate.py ADDED
@@ -0,0 +1,162 @@
1
+ """Move a workspace from one on-disk layout to the next — R1.5.
2
+
3
+ Layout 1 was a directory per stage: `anchor/`, `board/`, `matte/`, `row/`. Layout 2 is a
4
+ directory per artifact kind, with art directions under `sheet/`. The names changed
5
+ because the two questions they were answering had come apart — see the spec.
6
+
7
+ **Nothing migrates on its own.** A command that reorganises somebody's disk in the middle
8
+ of another command is exactly what nobody wants to find out about afterwards, and these
9
+ are directories that paid calls produced. So this is its own command, it prints what it
10
+ would do before it does anything, and `--dry-run` is how you see that without the move.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ from pathlib import Path
17
+
18
+ from . import workspace
19
+
20
+ #: The old directory, and the artifact directory it becomes. `row` carries a variant:
21
+ #: layout 1 had one row per sprite because it had no idea an art direction existed, and
22
+ #: the one it has is whatever was closed — `as-is` is the honest name for that.
23
+ MOVES: tuple[tuple[str, str], ...] = (
24
+ ("anchor", "anchor"),
25
+ ("board", "frames"),
26
+ ("matte", "cutout"),
27
+ ("row", "sheet/as-is"),
28
+ )
29
+
30
+ #: How a layout-1 stage entry named what it produced, and the artifact key it is now.
31
+ PRODUCED: dict[str, str] = {
32
+ "anchor": "anchor",
33
+ "board": "frames",
34
+ "matte": "cutout",
35
+ "row": "sheet:as-is",
36
+ }
37
+
38
+
39
+ def planned(asset: str) -> list[tuple[Path, Path]]:
40
+ """The moves this asset needs, in order. Empty when there is nothing to do."""
41
+ directory = workspace.asset_dir(asset)
42
+ moves = []
43
+ for old, new in MOVES:
44
+ source = directory / old
45
+ target = directory / new
46
+ if source.is_dir() and source != target and not target.exists():
47
+ moves.append((source, target))
48
+ return moves
49
+
50
+
51
+ def blocked(asset: str) -> list[tuple[Path, Path]]:
52
+ """The moves this asset needs and cannot make, because something is already there.
53
+
54
+ Rare, and it has to stop the migration rather than be skipped. Rewriting the state
55
+ while the files stay put would point `dir` at a directory holding somebody else's
56
+ contents: the real output is still on disk, orphaned, invisible to `show` and to
57
+ every stage that reads its input through the state file — and the wrong directory
58
+ is what the next paid stage would read.
59
+ """
60
+ directory = workspace.asset_dir(asset)
61
+ return [
62
+ (directory / old, directory / new)
63
+ for old, new in MOVES
64
+ if (directory / old).is_dir() and old != new and (directory / new).exists()
65
+ ]
66
+
67
+
68
+ def rewrite_state(state: workspace.State) -> workspace.State:
69
+ """Point the state at the new directories and fill in `artifacts` — R1.4.
70
+
71
+ `artifacts` is built from what is on disk rather than from the old state, because the
72
+ old state never listed files. That makes this the one place the two records are
73
+ allowed to be derived from each other, and it happens once.
74
+ """
75
+ for name, entry in state.stages.items():
76
+ produced = entry.get("produced")
77
+ moved = PRODUCED.get(produced, produced)
78
+ if moved is None:
79
+ continue
80
+ entry["produced"] = moved
81
+ entry["dir"] = moved.replace(":", "/")
82
+
83
+ # A layout-1 `produced` that is none of the four falls through unchanged, and it
84
+ # came out of the same editable file every other recorded path does.
85
+ target = workspace.inside(state.name, entry["dir"])
86
+ state.artifacts[moved] = {
87
+ "dir": entry["dir"],
88
+ "files": sorted(path.name for path in target.glob("*") if path.is_file())
89
+ if target.is_dir()
90
+ else [],
91
+ "stage": name,
92
+ "prompt": entry.get("prompt"),
93
+ }
94
+ state.layout = workspace.LAYOUT
95
+ return state
96
+
97
+
98
+ class Blocked(workspace.StageRefused):
99
+ """A migration that cannot run without losing track of something."""
100
+
101
+
102
+ def run(asset: str, *, dry_run: bool) -> list[tuple[Path, Path]]:
103
+ """Migrate one asset. Returns the moves, whether or not they were made."""
104
+ state = workspace.load(asset)
105
+ stuck = blocked(asset)
106
+ if stuck:
107
+ where = ", ".join(target.name for _, target in stuck)
108
+ raise Blocked(
109
+ f"{asset}: {where} already exists, so the old directory cannot move there. "
110
+ f"Nothing was changed. Move or remove it, then migrate again."
111
+ )
112
+ moves = planned(asset)
113
+ if dry_run:
114
+ return moves
115
+ for source, target in moves:
116
+ target.parent.mkdir(parents=True, exist_ok=True)
117
+ source.rename(target)
118
+ workspace.save(rewrite_state(state))
119
+ return moves
120
+
121
+
122
+ def cmd_migrate(args: argparse.Namespace) -> int:
123
+ """Migrate one asset, or say what every outdated one would need.
124
+
125
+ ASCII only, for the reason `workspace.cmd_status` is.
126
+ """
127
+ names = [workspace.check_name(args.name)] if args.name else workspace.known()
128
+ if not names:
129
+ print(f"no assets under {workspace.assets_root()}")
130
+ return 0
131
+
132
+ touched = refused = 0
133
+ for name in names:
134
+ state = workspace.load(name)
135
+ if not state.outdated():
136
+ if args.name:
137
+ print(f"{name} already layout {state.layout}; nothing to migrate")
138
+ continue
139
+ touched += 1
140
+ try:
141
+ moves = run(name, dry_run=args.dry_run)
142
+ except Blocked:
143
+ # Asked about one asset, a refusal is the answer and gets the exit code a
144
+ # refusal gets everywhere else. Asked about all of them, one that cannot move
145
+ # is not a reason to fail the others: it is named, and the batch goes on.
146
+ if args.name:
147
+ raise
148
+ refused += 1
149
+ print(f"{name} refused: it cannot move onto a directory that is already there")
150
+ continue
151
+ verb = "would move" if args.dry_run else "moved"
152
+ print(f"{name} layout {state.layout} -> {workspace.LAYOUT}")
153
+ for source, target in moves:
154
+ print(f" {verb} {source.name} -> {target.relative_to(workspace.asset_dir(name))}")
155
+ if not moves:
156
+ print(" nothing on disk to move; the state file is rewritten")
157
+
158
+ if args.dry_run and touched > refused:
159
+ print("nothing was moved; run again without --dry-run")
160
+ if refused:
161
+ print(f"{refused} asset(s) refused; nothing was changed for them")
162
+ return 0
spritegen/prompts.py ADDED
@@ -0,0 +1,96 @@
1
+ """The prompt that produced an artifact, kept where the artifact is — R2.1 to R2.4.
2
+
3
+ An image survives its own reason. The prompt arrives through `--prompt-file`, from
4
+ somewhere outside the workspace, or through `--prompt`, from a shell that forgets it the
5
+ moment the command returns — and once the paid call is over, what is left on disk is a
6
+ picture nobody can regenerate or amend. Under autonomy that is worse still: the file that
7
+ was pointed at can be edited or deleted afterwards, so a *reference* to it is not a
8
+ record of anything.
9
+
10
+ So the text is copied in, and it is copied **before the call**: a prompt that only gets
11
+ written when the call succeeds is missing exactly when it is most wanted.
12
+
13
+ **Versioned by content, not by time.** Sending the same prompt again is not a new version
14
+ of it — nothing changed, and a run that produced the same words should point at the words
15
+ it produced. Editing one is, and the old text stays: `anchor.v1.md` is how you find out
16
+ what the first attempt actually asked for.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from pathlib import Path
23
+
24
+ #: `<kind>.v<N>.md`, and nothing else in the prompts directory is one.
25
+ PATTERN = re.compile(r"^(?P<kind>.+)\.v(?P<version>\d+)\.md$")
26
+
27
+
28
+ def directory(asset_dir: Path) -> Path:
29
+ """Where this sprite's prompts live."""
30
+ return asset_dir / "prompts"
31
+
32
+
33
+ def name(kind: str, version: int) -> str:
34
+ return f"{kind}.v{version}.md"
35
+
36
+
37
+ def versions(asset_dir: Path, kind: str) -> list[int]:
38
+ """Every version of `kind` on disk, oldest first."""
39
+ found = []
40
+ for path in directory(asset_dir).glob(f"{kind}.v*.md"):
41
+ match = PATTERN.match(path.name)
42
+ if match and match.group("kind") == kind:
43
+ found.append(int(match.group("version")))
44
+ return sorted(found)
45
+
46
+
47
+ def read(asset_dir: Path, kind: str, version: int) -> str:
48
+ """One stored prompt, verbatim."""
49
+ path = directory(asset_dir) / name(kind, version)
50
+ if not path.is_file():
51
+ raise FileNotFoundError(f"no prompt {kind} v{version} in {directory(asset_dir)}")
52
+ return path.read_text(encoding="utf-8")
53
+
54
+
55
+ def store(asset_dir: Path, kind: str, text: str) -> str:
56
+ """Keep `text` as a version of `kind`, and return the filename to record — R2.1, R2.2.
57
+
58
+ Unchanged text returns the version it already is. That is the whole of "versioned by
59
+ content": four candidates from one prompt cite one version, and the fifth run after an
60
+ edit cites a new one.
61
+
62
+ Compared against the newest version only. A prompt that was changed and then changed
63
+ back is a third version, not a re-use of the first — the history is a sequence of what
64
+ was asked, and collapsing a revert would hide that the middle one ever happened.
65
+ """
66
+ text = text.rstrip() + "\n"
67
+ existing = versions(asset_dir, kind)
68
+ if existing and read(asset_dir, kind, existing[-1]) == text:
69
+ return name(kind, existing[-1])
70
+
71
+ version = (existing[-1] + 1) if existing else 1
72
+ target = directory(asset_dir)
73
+ target.mkdir(parents=True, exist_ok=True)
74
+ (target / name(kind, version)).write_text(text, encoding="utf-8")
75
+ return name(kind, version)
76
+
77
+
78
+ def resolve(prompt: str | None, prompt_file: str | None, what: str) -> str:
79
+ """The prompt text, from one source or the other and never from both.
80
+
81
+ Shared by every stage that takes a prompt, because they all had the same six lines
82
+ and one of them would eventually have drifted.
83
+ """
84
+ if prompt and prompt_file:
85
+ raise ValueError("give --prompt or --prompt-file, not both")
86
+ if prompt:
87
+ return prompt
88
+ if not prompt_file:
89
+ raise ValueError(f"{what} needs --prompt or --prompt-file")
90
+ path = Path(prompt_file)
91
+ if not path.is_file():
92
+ raise FileNotFoundError(f"no prompt file at {path}")
93
+ text = path.read_text(encoding="utf-8").strip()
94
+ if not text:
95
+ raise ValueError(f"{path} is empty")
96
+ return text
spritegen/rrdb.py ADDED
@@ -0,0 +1,91 @@
1
+ """RRDBNet, the network Real-ESRGAN's weights are for.
2
+
3
+ Ported from an `upscale_atlas.py` that reimplements it rather than depending on
4
+ `basicsr` — the upstream package pins an old torchvision and does not
5
+ install alongside a current torch. What was two classes there is one here: `RRDBNet6B`
6
+ differed from `RRDBNet` in the default block count and in nothing else.
7
+
8
+ **This module imports torch at the top, and so nothing may import it at package level.**
9
+ `upscale` resolves it inside the function that builds the network, which is what keeps
10
+ `status` and `cost` answering on a machine with no torch on it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import torch
16
+ import torch.nn.functional as functional
17
+ from torch import nn
18
+
19
+
20
+ class ResidualDenseBlock(nn.Module):
21
+ """Five convolutions, each seeing everything before it, added back at 0.2."""
22
+
23
+ def __init__(self, nf: int = 64, gc: int = 32):
24
+ super().__init__()
25
+ self.conv1 = nn.Conv2d(nf, gc, 3, 1, 1)
26
+ self.conv2 = nn.Conv2d(nf + gc, gc, 3, 1, 1)
27
+ self.conv3 = nn.Conv2d(nf + 2 * gc, gc, 3, 1, 1)
28
+ self.conv4 = nn.Conv2d(nf + 3 * gc, gc, 3, 1, 1)
29
+ self.conv5 = nn.Conv2d(nf + 4 * gc, nf, 3, 1, 1)
30
+ self.lrelu = nn.LeakyReLU(0.2, True)
31
+
32
+ def forward(self, x):
33
+ x1 = self.lrelu(self.conv1(x))
34
+ x2 = self.lrelu(self.conv2(torch.cat((x, x1), 1)))
35
+ x3 = self.lrelu(self.conv3(torch.cat((x, x1, x2), 1)))
36
+ x4 = self.lrelu(self.conv4(torch.cat((x, x1, x2, x3), 1)))
37
+ x5 = self.conv5(torch.cat((x, x1, x2, x3, x4), 1))
38
+ return x5 * 0.2 + x
39
+
40
+
41
+ class RRDB(nn.Module):
42
+ """Three of those in a row, added back at 0.2 again."""
43
+
44
+ def __init__(self, nf: int = 64, gc: int = 32):
45
+ super().__init__()
46
+ self.rdb1 = ResidualDenseBlock(nf, gc)
47
+ self.rdb2 = ResidualDenseBlock(nf, gc)
48
+ self.rdb3 = ResidualDenseBlock(nf, gc)
49
+
50
+ def forward(self, x):
51
+ out = self.rdb3(self.rdb2(self.rdb1(x)))
52
+ return out * 0.2 + x
53
+
54
+
55
+ class RRDBNet(nn.Module):
56
+ """The whole network. `nb` is 23 for the general weights and 6 for the anime ones.
57
+
58
+ The upsampling is two nearest-neighbour doublings with a convolution after each, so
59
+ the network is 4x and only 4x. Any other factor is a resize of what it returned.
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ in_nc: int = 3,
65
+ out_nc: int = 3,
66
+ nf: int = 64,
67
+ nb: int = 23,
68
+ gc: int = 32,
69
+ scale: int = 4,
70
+ ):
71
+ super().__init__()
72
+ self.scale = scale
73
+ self.conv_first = nn.Conv2d(in_nc, nf, 3, 1, 1)
74
+ self.body = nn.Sequential(*[RRDB(nf, gc) for _ in range(nb)])
75
+ self.conv_body = nn.Conv2d(nf, nf, 3, 1, 1)
76
+ self.conv_up1 = nn.Conv2d(nf, nf, 3, 1, 1)
77
+ self.conv_up2 = nn.Conv2d(nf, nf, 3, 1, 1)
78
+ self.conv_hr = nn.Conv2d(nf, nf, 3, 1, 1)
79
+ self.conv_last = nn.Conv2d(nf, out_nc, 3, 1, 1)
80
+ self.lrelu = nn.LeakyReLU(0.2, True)
81
+
82
+ def forward(self, x):
83
+ feat = self.conv_first(x)
84
+ feat = feat + self.conv_body(self.body(feat))
85
+ feat = self.lrelu(
86
+ self.conv_up1(functional.interpolate(feat, scale_factor=2, mode="nearest"))
87
+ )
88
+ feat = self.lrelu(
89
+ self.conv_up2(functional.interpolate(feat, scale_factor=2, mode="nearest"))
90
+ )
91
+ return self.conv_last(self.lrelu(self.conv_hr(feat)))
spritegen/settings.py ADDED
@@ -0,0 +1,127 @@
1
+ """Every setting this tool has, in one place — the environment, a `.env`, or a default.
2
+
3
+ Before this, configuration was four unrelated habits: `FAL_KEY` read out of `os.environ`
4
+ inside `fal`, `SPRITEGEN_ASSETS` read out of it inside `workspace`, a `.env` loaded by the
5
+ CLI into the process, and a handful of module constants nobody could override without
6
+ editing the source. Each was defensible alone; together they meant no answer to "what can
7
+ be configured", and two of them could disagree about whether a variable had been set.
8
+
9
+ **Not cached, deliberately.** A CLI runs once and exits, so the saving would be
10
+ microseconds; what it would cost is that a variable set after the first read stops taking
11
+ effect, which is precisely what a test does when it points the assets somewhere else. Read
12
+ it where you need it.
13
+
14
+ The import stays out of module scope everywhere it is used, for the same reason `fal` and
15
+ `Pillow` do: `status` and `cost` must answer without paying for anything they do not need.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+ from typing import Literal
22
+
23
+ from pydantic import AliasChoices, Field
24
+ from pydantic_settings import BaseSettings, SettingsConfigDict
25
+
26
+ #: Where a downloaded model weight goes. Outside any workspace: it is the same file for
27
+ #: every project, it is 64 MB or more, and it can always be fetched again.
28
+ DEFAULT_CACHE = Path.home() / ".cache" / "spritegen"
29
+
30
+
31
+ def search_from(start: Path | None = None) -> tuple[Path, ...]:
32
+ """The directories a search may look in: the start, and its parents up to a ceiling.
33
+
34
+ **The ceiling is the point of this.** Searching upward is right — a command is typed
35
+ from wherever is convenient, not from the top of the thing it concerns — but upward
36
+ with no stopping condition means the filesystem root, and everything between here and
37
+ there belongs to somebody else. A `.env` in a shared parent, on a mounted drive, or in
38
+ a CI runner's workspace root would be read as this tool's own configuration, and it
39
+ can set the credential, where the assets are, and which hosts a download may come
40
+ from.
41
+
42
+ So the walk stops at the first repository boundary, or at the home directory,
43
+ whichever comes first. Neither found — the working directory is somewhere with no
44
+ repository above it and outside the home directory — and the search is the working
45
+ directory alone, which is the conservative answer rather than the far-reaching one.
46
+ """
47
+ start = Path.cwd() if start is None else start
48
+ home = Path.home()
49
+ chain: list[Path] = []
50
+ for base in (start, *start.parents):
51
+ chain.append(base)
52
+ if (base / ".git").exists() or base == home:
53
+ return tuple(chain)
54
+ return (start,)
55
+
56
+
57
+ def dotenv(start: Path | None = None) -> Path | None:
58
+ """The nearest `.env` at or above the working directory, within the search ceiling.
59
+
60
+ An exported variable still wins over the file — that is pydantic-settings' own order,
61
+ and it is what makes a one-off `FAL_KEY=... spritegen anchor ...` work while a stale
62
+ key sits in the file.
63
+ """
64
+ for base in search_from(start):
65
+ candidate = base / ".env"
66
+ if candidate.is_file():
67
+ return candidate
68
+ return None
69
+
70
+
71
+ class Settings(BaseSettings):
72
+ """What can be configured, and what it is when nobody says.
73
+
74
+ Every field is `SPRITEGEN_<NAME>` in the environment. `fal_key` also answers to bare
75
+ `FAL_KEY`, which is what fal's own documentation and every existing `.env` use — an
76
+ alias costs one line and breaking them would cost everyone a rewrite.
77
+ """
78
+
79
+ model_config = SettingsConfigDict(
80
+ env_prefix="SPRITEGEN_",
81
+ env_file=None,
82
+ extra="ignore",
83
+ case_sensitive=False,
84
+ )
85
+
86
+ #: Where the sprites live. Unset means discover it — see `workspace.assets_root`.
87
+ assets: Path | None = None
88
+
89
+ #: The fal credential. Absent is not an error until something tries to spend.
90
+ fal_key: str | None = Field(
91
+ default=None, validation_alias=AliasChoices("SPRITEGEN_FAL_KEY", "FAL_KEY", "fal_key")
92
+ )
93
+
94
+ #: Downloaded model weights, shared across every project on this machine.
95
+ cache: Path = DEFAULT_CACHE
96
+
97
+ #: The sheet cell, in pixels. A project with another one can say so once here rather
98
+ #: than passing `--cell` to every command.
99
+ cell: int = 166
100
+
101
+ #: Which Real-ESRGAN weights the local upscale uses, and where it runs. `auto` means
102
+ #: CUDA where there is one — reported by the command, never assumed silently.
103
+ upscale_model: Literal["anime", "general"] = "anime"
104
+ upscale_device: Literal["auto", "cuda", "cpu"] = "auto"
105
+
106
+ #: Whether the background comes off locally or through a paid endpoint. Local is the
107
+ #: default because it costs nothing and this machine has the GPU for it; the paid one
108
+ #: is there for a machine that does not.
109
+ matte_backend: Literal["local", "fal"] = "local"
110
+ matte_model: str = "birefnet-general"
111
+
112
+ #: Extra hosts a downloaded URL may come from, on top of the ones built in. A
113
+ #: comma-separated list, because that is what an environment variable can carry.
114
+ allowed_hosts: str = ""
115
+
116
+ def extra_hosts(self) -> tuple[str, ...]:
117
+ return tuple(part.strip() for part in self.allowed_hosts.split(",") if part.strip())
118
+
119
+
120
+ def load(**overrides) -> Settings:
121
+ """The settings as they are right now.
122
+
123
+ `overrides` is for a caller that already has the answer — a test, or a command-line
124
+ option that beats the environment. It is not a way to mutate anything: this builds a
125
+ new object every time and nothing is stored.
126
+ """
127
+ return Settings(_env_file=dotenv(), **overrides)