comicforge 0.0.1__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.
- comicforge/__init__.py +10 -0
- comicforge/__main__.py +5 -0
- comicforge/_skill/SKILL.md +325 -0
- comicforge/_skill/reference.md +644 -0
- comicforge/bubbles.py +133 -0
- comicforge/cli.py +396 -0
- comicforge/inspire.py +246 -0
- comicforge/library.py +235 -0
- comicforge/pixelart.py +80 -0
- comicforge/render.py +560 -0
- comicforge/scaffold.py +144 -0
- comicforge/scene.py +112 -0
- comicforge/validate.py +192 -0
- comicforge-0.0.1.dist-info/METADATA +136 -0
- comicforge-0.0.1.dist-info/RECORD +17 -0
- comicforge-0.0.1.dist-info/WHEEL +4 -0
- comicforge-0.0.1.dist-info/entry_points.txt +3 -0
comicforge/inspire.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Generate reference images from a project theme + per-item descriptions.
|
|
2
|
+
|
|
3
|
+
These images are **inspiration** for hand/LLM-authored SVG art — they are *not*
|
|
4
|
+
shipped assets. The idea: describe a character or scene in words, blend it with a
|
|
5
|
+
project-wide theme (style, color scale, mood), and let an image model paint a
|
|
6
|
+
reference to author crisp SVG from. Do not auto-vectorize the output; it breaks
|
|
7
|
+
overlay registration and the editable slot structure.
|
|
8
|
+
|
|
9
|
+
comicforge inspire examples/pes/references.yaml
|
|
10
|
+
|
|
11
|
+
Requires the optional `inspire` extra::
|
|
12
|
+
|
|
13
|
+
pip install "comicforge[inspire]"
|
|
14
|
+
|
|
15
|
+
and a ``REPLICATE_API_TOKEN`` (read from the environment, or a ``.env`` file next
|
|
16
|
+
to the references spec / in the cwd). Use ``--dry-run`` to preview the composed
|
|
17
|
+
prompts without calling the API (and without needing a token).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import yaml
|
|
28
|
+
|
|
29
|
+
DEFAULT_MODEL = "google/imagen-3"
|
|
30
|
+
DEFAULT_ASPECT = "1:1"
|
|
31
|
+
# Imagen on Replicate caps at ~6 req/min; pause between live calls.
|
|
32
|
+
RATE_LIMIT_SECONDS = 11
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Theme:
|
|
37
|
+
"""Project-wide style applied to every generated reference image."""
|
|
38
|
+
|
|
39
|
+
style: str = ""
|
|
40
|
+
palette: list[str] = field(default_factory=list)
|
|
41
|
+
mood: str = ""
|
|
42
|
+
negative: str = "No text, no letters, no words, no watermark, no signature."
|
|
43
|
+
aspect_ratio: str = DEFAULT_ASPECT
|
|
44
|
+
model: str = DEFAULT_MODEL
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Item:
|
|
49
|
+
"""One thing to depict: an id (→ filename) and a free-text description."""
|
|
50
|
+
|
|
51
|
+
id: str
|
|
52
|
+
prompt: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_theme(path: Path) -> Theme:
|
|
56
|
+
"""Load ``theme.yaml``. A missing file yields an empty (defaults) theme."""
|
|
57
|
+
if not path.exists():
|
|
58
|
+
return Theme()
|
|
59
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
60
|
+
palette = data.get("palette") or []
|
|
61
|
+
if isinstance(palette, str):
|
|
62
|
+
palette = [palette]
|
|
63
|
+
return Theme(
|
|
64
|
+
style=str(data.get("style", "")).strip(),
|
|
65
|
+
palette=[str(c) for c in palette],
|
|
66
|
+
mood=str(data.get("mood", "")).strip(),
|
|
67
|
+
negative=str(data.get("negative", Theme.negative)).strip(),
|
|
68
|
+
aspect_ratio=str(data.get("aspect_ratio", DEFAULT_ASPECT)),
|
|
69
|
+
model=str(data.get("model", DEFAULT_MODEL)),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_items(path: Path) -> list[Item]:
|
|
74
|
+
"""Load the references spec — a bare list, or a mapping with ``items:``.
|
|
75
|
+
|
|
76
|
+
Each entry needs an id (``id`` / ``name``) and a description
|
|
77
|
+
(``prompt`` / ``description`` / ``desc``).
|
|
78
|
+
"""
|
|
79
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
80
|
+
raw = data.get("items", []) if isinstance(data, dict) else data
|
|
81
|
+
if not isinstance(raw, list):
|
|
82
|
+
# malformed config, not a type bug -> ValueError is the right contract
|
|
83
|
+
raise ValueError( # noqa: TRY004
|
|
84
|
+
f"{path}: expected a list of items (or an 'items:' key)"
|
|
85
|
+
)
|
|
86
|
+
items: list[Item] = []
|
|
87
|
+
for i, entry in enumerate(raw):
|
|
88
|
+
if not isinstance(entry, dict):
|
|
89
|
+
raise ValueError(f"{path}: item {i} is not a mapping") # noqa: TRY004
|
|
90
|
+
ident = entry.get("id") or entry.get("name")
|
|
91
|
+
prompt = entry.get("prompt") or entry.get("description") or entry.get("desc")
|
|
92
|
+
if not ident or not prompt:
|
|
93
|
+
raise ValueError(f"{path}: item {i} needs both an id and a prompt")
|
|
94
|
+
items.append(Item(id=str(ident), prompt=str(prompt).strip()))
|
|
95
|
+
return items
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_prompt(theme: Theme, item: Item) -> str:
|
|
99
|
+
"""Compose the final image prompt: theme style + subject + palette + mood."""
|
|
100
|
+
parts: list[str] = []
|
|
101
|
+
if theme.style:
|
|
102
|
+
parts.append(theme.style)
|
|
103
|
+
parts.append(f"Subject: {item.prompt}")
|
|
104
|
+
if theme.palette:
|
|
105
|
+
parts.append("Use this color palette: " + ", ".join(theme.palette) + ".")
|
|
106
|
+
if theme.mood:
|
|
107
|
+
parts.append(f"Mood: {theme.mood}.")
|
|
108
|
+
if theme.negative:
|
|
109
|
+
parts.append(theme.negative)
|
|
110
|
+
return "\n\n".join(parts)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _load_dotenv(*dirs: Path) -> None:
|
|
114
|
+
"""Best-effort: load a ``.env`` from the given dirs if python-dotenv is present."""
|
|
115
|
+
try:
|
|
116
|
+
from dotenv import ( # noqa: PLC0415 # ty: ignore[unresolved-import]
|
|
117
|
+
load_dotenv,
|
|
118
|
+
)
|
|
119
|
+
except ImportError:
|
|
120
|
+
return
|
|
121
|
+
for d in dirs:
|
|
122
|
+
env = d / ".env"
|
|
123
|
+
if env.exists():
|
|
124
|
+
load_dotenv(env)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _generate_one(model: str, prompt: str, aspect_ratio: str) -> bytes:
|
|
128
|
+
"""Call Replicate and return the raw image bytes."""
|
|
129
|
+
# lazy: only needed for live generation, keeps Replicate an optional dep
|
|
130
|
+
import replicate # noqa: PLC0415 # ty: ignore[unresolved-import]
|
|
131
|
+
|
|
132
|
+
output = replicate.run(
|
|
133
|
+
model,
|
|
134
|
+
input={
|
|
135
|
+
"prompt": prompt,
|
|
136
|
+
"aspect_ratio": aspect_ratio,
|
|
137
|
+
"output_format": "png",
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
# Imagen returns a single FileOutput; some models return a list.
|
|
141
|
+
if isinstance(output, list):
|
|
142
|
+
output = output[0]
|
|
143
|
+
if hasattr(output, "read"):
|
|
144
|
+
return output.read()
|
|
145
|
+
raise RuntimeError(f"unexpected Replicate output: {type(output).__name__}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def generate(
|
|
149
|
+
refs_path: Path,
|
|
150
|
+
out_dir: Path | None = None,
|
|
151
|
+
*,
|
|
152
|
+
theme_path: Path | None = None,
|
|
153
|
+
only: set[str] | None = None,
|
|
154
|
+
force: bool = False,
|
|
155
|
+
review: bool = False,
|
|
156
|
+
dry_run: bool = False,
|
|
157
|
+
) -> list[Path]:
|
|
158
|
+
"""Generate reference images for every item in ``refs_path``.
|
|
159
|
+
|
|
160
|
+
Paths in the spec resolve against the spec's own directory. ``theme.yaml`` and
|
|
161
|
+
the output ``references/`` dir default to siblings of the spec. Each item
|
|
162
|
+
writes ``<id>.png`` plus an ``<id>.prompt.txt`` sidecar; ``--dry-run`` writes
|
|
163
|
+
only the sidecars. Returns the list of files written.
|
|
164
|
+
"""
|
|
165
|
+
refs_path = refs_path.resolve()
|
|
166
|
+
base = refs_path.parent
|
|
167
|
+
theme = load_theme(theme_path.resolve() if theme_path else base / "theme.yaml")
|
|
168
|
+
items = load_items(refs_path)
|
|
169
|
+
out_dir = (out_dir or base / "references").resolve()
|
|
170
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
|
|
172
|
+
if not dry_run:
|
|
173
|
+
_load_dotenv(base, Path.cwd())
|
|
174
|
+
|
|
175
|
+
written: list[Path] = []
|
|
176
|
+
targets = [it for it in items if only is None or it.id in only]
|
|
177
|
+
print(f"[inspire] {len(targets)} item(s) → {out_dir} (model: {theme.model})") # noqa: T201
|
|
178
|
+
|
|
179
|
+
for item in targets:
|
|
180
|
+
prompt = build_prompt(theme, item)
|
|
181
|
+
prompt_path = out_dir / f"{item.id}.prompt.txt"
|
|
182
|
+
prompt_path.write_text(prompt, encoding="utf-8")
|
|
183
|
+
written.append(prompt_path)
|
|
184
|
+
|
|
185
|
+
img_path = out_dir / f"{item.id}.png"
|
|
186
|
+
if dry_run:
|
|
187
|
+
print(f" {item.id}: prompt written (dry-run)") # noqa: T201
|
|
188
|
+
continue
|
|
189
|
+
if img_path.exists() and not force and only is None:
|
|
190
|
+
print(f" {item.id}: cached, skipping") # noqa: T201
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
print(f" {item.id}: generating…") # noqa: T201
|
|
194
|
+
try:
|
|
195
|
+
data = _generate_one(theme.model, prompt, theme.aspect_ratio)
|
|
196
|
+
except Exception as e: # surface and keep going on the rest
|
|
197
|
+
print(f" {item.id}: FAILED — {e}") # noqa: T201
|
|
198
|
+
continue
|
|
199
|
+
img_path.write_bytes(data)
|
|
200
|
+
written.append(img_path)
|
|
201
|
+
print(f" {item.id}: wrote {img_path.name} ({len(data) // 1024} KB)") # noqa: T201
|
|
202
|
+
time.sleep(RATE_LIMIT_SECONDS)
|
|
203
|
+
|
|
204
|
+
if review:
|
|
205
|
+
written.append(_write_review(out_dir, targets))
|
|
206
|
+
|
|
207
|
+
return written
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _write_review(out_dir: Path, items: list[Item]) -> Path:
|
|
211
|
+
"""Write a self-contained HTML grid of the generated images + prompts."""
|
|
212
|
+
cards: list[str] = []
|
|
213
|
+
for item in items:
|
|
214
|
+
img_path = out_dir / f"{item.id}.png"
|
|
215
|
+
if img_path.exists():
|
|
216
|
+
b64 = base64.b64encode(img_path.read_bytes()).decode("ascii")
|
|
217
|
+
src = f"data:image/png;base64,{b64}"
|
|
218
|
+
else:
|
|
219
|
+
src = ""
|
|
220
|
+
prompt_path = out_dir / f"{item.id}.prompt.txt"
|
|
221
|
+
prompt = prompt_path.read_text(encoding="utf-8") if prompt_path.exists() else ""
|
|
222
|
+
cards.append(
|
|
223
|
+
f'<div class="card">'
|
|
224
|
+
f'<img src="{src}" alt="{item.id}" loading="lazy">'
|
|
225
|
+
f"<div class=label>{item.id}</div>"
|
|
226
|
+
f"<details><summary>prompt</summary><pre>{prompt}</pre></details>"
|
|
227
|
+
f"</div>"
|
|
228
|
+
)
|
|
229
|
+
html = (
|
|
230
|
+
"<!doctype html><meta charset=utf-8><title>inspire review</title>"
|
|
231
|
+
"<style>body{font-family:sans-serif;padding:2rem;background:#fafafa}"
|
|
232
|
+
".grid{display:grid;gap:1.5rem;"
|
|
233
|
+
"grid-template-columns:repeat(auto-fill,minmax(260px,1fr))}"
|
|
234
|
+
".card{background:#fff;border-radius:12px;padding:1rem;text-align:center;"
|
|
235
|
+
"box-shadow:0 2px 8px rgba(0,0,0,.08)}"
|
|
236
|
+
".card img{width:100%;border-radius:8px;background:#f0f0f0}"
|
|
237
|
+
".label{margin-top:.5rem;font-weight:600}"
|
|
238
|
+
"details{margin-top:.5rem;text-align:left}"
|
|
239
|
+
"pre{font-size:.75rem;white-space:pre-wrap;background:#f5f5f5;"
|
|
240
|
+
"padding:.5rem;border-radius:4px}</style>"
|
|
241
|
+
f"<h1>inspire review · {len(items)} item(s)</h1>"
|
|
242
|
+
f"<div class=grid>{''.join(cards)}</div>"
|
|
243
|
+
)
|
|
244
|
+
path = out_dir / "review.html"
|
|
245
|
+
path.write_text(html, encoding="utf-8")
|
|
246
|
+
return path
|
comicforge/library.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Load the character library and compose a posed character.
|
|
2
|
+
|
|
3
|
+
A character is an **identity** (name, label, …) that owns one or more **poses**.
|
|
4
|
+
A pose is a body drawn in its own local viewBox; expressions ("face", …) are
|
|
5
|
+
*shared* overlays authored once and re-registered onto each pose.
|
|
6
|
+
|
|
7
|
+
Two on-disk shapes, both loaded here:
|
|
8
|
+
|
|
9
|
+
*Flat (single pose):* the classic layout — everything in one viewBox::
|
|
10
|
+
|
|
11
|
+
<name>/
|
|
12
|
+
base.svg the body
|
|
13
|
+
<slot>-<variant>.svg stackable overlays in the SAME viewBox
|
|
14
|
+
character.yaml name, label, viewbox, slots, default
|
|
15
|
+
|
|
16
|
+
*Posed (multiple poses):* the body changes between poses (sit vs walk), so each
|
|
17
|
+
pose gets its own ``base.svg``; the shared overlays live at the character root
|
|
18
|
+
and are translated onto each pose by an *anchor* (a reference point — e.g. the
|
|
19
|
+
head centre — that the overlays are drawn around)::
|
|
20
|
+
|
|
21
|
+
<name>/
|
|
22
|
+
character.yaml name, label, anchor, slots (SHARED), default, poses
|
|
23
|
+
<slot>-<variant>.svg SHARED overlays, drawn around the canonical anchor
|
|
24
|
+
poses/
|
|
25
|
+
<pose>/
|
|
26
|
+
pose.yaml viewbox, anchor, slots (pose-specific), default
|
|
27
|
+
base.svg this pose's body
|
|
28
|
+
<slot>-<variant>.svg pose-specific overlays (optional)
|
|
29
|
+
|
|
30
|
+
Composition = pose base + pose-specific overlays + shared overlays (each shifted
|
|
31
|
+
by ``pose.anchor - character.anchor``), stacked in the pose's local viewBox, then
|
|
32
|
+
wrapped in a ``<g transform>`` that places and scales it inside a panel.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import re
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
import yaml
|
|
42
|
+
|
|
43
|
+
_SVG_INNER = re.compile(r"<svg[^>]*>(.*)</svg>", re.DOTALL)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _inner(svg_path: Path) -> str:
|
|
47
|
+
text = svg_path.read_text(encoding="utf-8")
|
|
48
|
+
m = _SVG_INNER.search(text)
|
|
49
|
+
if not m:
|
|
50
|
+
raise ValueError(f"{svg_path} is not a well-formed <svg>…</svg> file")
|
|
51
|
+
return m.group(1).strip()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _load_variants(dir_: Path, slots: dict[str, list[str]]) -> dict[str, str]:
|
|
55
|
+
"""Read every ``<slot>-<variant>.svg`` overlay in *dir_* into inner markup."""
|
|
56
|
+
out: dict[str, str] = {}
|
|
57
|
+
for slot, names in slots.items():
|
|
58
|
+
for v in names:
|
|
59
|
+
out[f"{slot}-{v}"] = _inner(dir_ / f"{slot}-{v}.svg")
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class Pose:
|
|
65
|
+
"""One body of a character, drawn in its own local viewBox."""
|
|
66
|
+
|
|
67
|
+
name: str
|
|
68
|
+
w: float
|
|
69
|
+
h: float
|
|
70
|
+
anchor: tuple[float, float] # where shared overlays land in this pose
|
|
71
|
+
base: str # inner svg markup
|
|
72
|
+
slots: dict[str, list[str]] = field(default_factory=dict) # pose-specific
|
|
73
|
+
defaults: dict[str, str] = field(default_factory=dict)
|
|
74
|
+
variants: dict[str, str] = field(default_factory=dict) # "slot-variant"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class Character:
|
|
79
|
+
name: str
|
|
80
|
+
label: str
|
|
81
|
+
anchor: tuple[float, float] # canonical point shared overlays draw around
|
|
82
|
+
slots: dict[str, list[str]] # SHARED slots (re-anchored onto each pose)
|
|
83
|
+
defaults: dict[str, str] # shared defaults (may include "pose")
|
|
84
|
+
variants: dict[str, str] # shared "slot-variant" -> inner
|
|
85
|
+
poses: dict[str, Pose]
|
|
86
|
+
default_pose: str
|
|
87
|
+
|
|
88
|
+
def resolve_pose(self, pose: str | None) -> Pose:
|
|
89
|
+
name = pose or self.default_pose
|
|
90
|
+
if name not in self.poses:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"{self.name}: no pose '{name}'. Available: {list(self.poses)}"
|
|
93
|
+
)
|
|
94
|
+
return self.poses[name]
|
|
95
|
+
|
|
96
|
+
def slots_for(self, pose: str | None = None) -> dict[str, list[str]]:
|
|
97
|
+
"""Every selectable slot for a pose: shared slots + that pose's own slots."""
|
|
98
|
+
return {**self.slots, **self.resolve_pose(pose).slots}
|
|
99
|
+
|
|
100
|
+
def compose_inner(self, selection: dict[str, str], pose: str | None = None) -> str:
|
|
101
|
+
"""Inner SVG for this character in the chosen pose with the chosen variants."""
|
|
102
|
+
p = self.resolve_pose(pose)
|
|
103
|
+
parts = [p.base]
|
|
104
|
+
|
|
105
|
+
# pose-specific overlays: drawn directly in the pose's own coordinates
|
|
106
|
+
for slot in p.slots: # manifest order = draw order
|
|
107
|
+
variant = selection.get(slot, p.defaults.get(slot))
|
|
108
|
+
if variant is None:
|
|
109
|
+
continue
|
|
110
|
+
if variant not in p.slots[slot]:
|
|
111
|
+
raise ValueError(
|
|
112
|
+
f"{self.name}/{p.name}: slot '{slot}' has no variant "
|
|
113
|
+
f"'{variant}'. Available: {p.slots[slot]}"
|
|
114
|
+
)
|
|
115
|
+
parts.append(p.variants[f"{slot}-{variant}"])
|
|
116
|
+
|
|
117
|
+
# shared overlays: authored around the canonical anchor, shifted onto this pose
|
|
118
|
+
dx, dy = p.anchor[0] - self.anchor[0], p.anchor[1] - self.anchor[1]
|
|
119
|
+
for slot in self.slots:
|
|
120
|
+
variant = selection.get(
|
|
121
|
+
slot, p.defaults.get(slot) or self.defaults.get(slot)
|
|
122
|
+
)
|
|
123
|
+
if variant is None:
|
|
124
|
+
continue
|
|
125
|
+
if variant not in self.slots[slot]:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"{self.name}: shared slot '{slot}' has no variant '{variant}'. "
|
|
128
|
+
f"Available: {self.slots[slot]}"
|
|
129
|
+
)
|
|
130
|
+
inner = self.variants[f"{slot}-{variant}"]
|
|
131
|
+
if dx or dy:
|
|
132
|
+
inner = f'<g transform="translate({dx:.2f},{dy:.2f})">\n{inner}\n</g>'
|
|
133
|
+
parts.append(inner)
|
|
134
|
+
return "\n".join(parts)
|
|
135
|
+
|
|
136
|
+
def place(self, selection, cx, cy, height, flip=False, pose=None) -> str:
|
|
137
|
+
"""Place the character centred at (cx, cy) scaled so it is `height` tall."""
|
|
138
|
+
p = self.resolve_pose(pose)
|
|
139
|
+
s = height / p.h
|
|
140
|
+
w, h = p.w * s, p.h * s
|
|
141
|
+
tx, ty = cx - w / 2, cy - h / 2
|
|
142
|
+
if flip:
|
|
143
|
+
transform = f"translate({tx + w:.2f},{ty:.2f}) scale({-s:.4f},{s:.4f})"
|
|
144
|
+
else:
|
|
145
|
+
transform = f"translate({tx:.2f},{ty:.2f}) scale({s:.4f})"
|
|
146
|
+
inner = self.compose_inner(selection, pose)
|
|
147
|
+
return f'<g transform="{transform}">\n{inner}\n</g>'
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _load_character(cdir: Path) -> Character:
|
|
151
|
+
man = yaml.safe_load((cdir / "character.yaml").read_text(encoding="utf-8"))
|
|
152
|
+
name = man["name"]
|
|
153
|
+
label = man.get("label", name)
|
|
154
|
+
anchor = tuple(man.get("anchor", (0.0, 0.0)))
|
|
155
|
+
shared_slots = man.get("slots", {})
|
|
156
|
+
shared_defaults = man.get("default", {})
|
|
157
|
+
shared_variants = _load_variants(cdir, shared_slots)
|
|
158
|
+
|
|
159
|
+
poses: dict[str, Pose] = {}
|
|
160
|
+
if "poses" in man:
|
|
161
|
+
for pname in man["poses"]:
|
|
162
|
+
pdir = cdir / "poses" / pname
|
|
163
|
+
pman = yaml.safe_load((pdir / "pose.yaml").read_text(encoding="utf-8"))
|
|
164
|
+
pw, ph = pman["viewbox"]
|
|
165
|
+
pslots = pman.get("slots", {})
|
|
166
|
+
poses[pname] = Pose(
|
|
167
|
+
name=pname,
|
|
168
|
+
w=pw,
|
|
169
|
+
h=ph,
|
|
170
|
+
anchor=tuple(pman.get("anchor", anchor)),
|
|
171
|
+
base=_inner(pdir / "base.svg"),
|
|
172
|
+
slots=pslots,
|
|
173
|
+
defaults=pman.get("default", {}),
|
|
174
|
+
variants=_load_variants(pdir, pslots),
|
|
175
|
+
)
|
|
176
|
+
default_pose = shared_defaults.get("pose") or man["poses"][0]
|
|
177
|
+
else:
|
|
178
|
+
# flat single-pose character: the body + overlays live at the root, in one
|
|
179
|
+
# viewBox. The shared overlays sit at the canonical anchor (translate 0).
|
|
180
|
+
w, h = man["viewbox"]
|
|
181
|
+
poses["default"] = Pose(
|
|
182
|
+
name="default", w=w, h=h, anchor=anchor, base=_inner(cdir / "base.svg")
|
|
183
|
+
)
|
|
184
|
+
default_pose = "default"
|
|
185
|
+
|
|
186
|
+
return Character(
|
|
187
|
+
name=name,
|
|
188
|
+
label=label,
|
|
189
|
+
anchor=anchor,
|
|
190
|
+
slots=shared_slots,
|
|
191
|
+
defaults=shared_defaults,
|
|
192
|
+
variants=shared_variants,
|
|
193
|
+
poses=poses,
|
|
194
|
+
default_pose=default_pose,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class Library:
|
|
199
|
+
def __init__(self, root: str | Path):
|
|
200
|
+
self.root = Path(root)
|
|
201
|
+
self._cache: dict[str, Character] = {}
|
|
202
|
+
|
|
203
|
+
def get(self, name: str) -> Character:
|
|
204
|
+
if name in self._cache:
|
|
205
|
+
return self._cache[name]
|
|
206
|
+
cdir = self.root / name
|
|
207
|
+
if not cdir.is_dir():
|
|
208
|
+
avail = [p.name for p in self.root.iterdir() if p.is_dir()]
|
|
209
|
+
raise KeyError(
|
|
210
|
+
f"character '{name}' not found in {self.root}. Have: {avail}"
|
|
211
|
+
)
|
|
212
|
+
char = _load_character(cdir)
|
|
213
|
+
self._cache[name] = char
|
|
214
|
+
return char
|
|
215
|
+
|
|
216
|
+
def manifest(self) -> dict:
|
|
217
|
+
"""Machine-readable contract: every character, its poses, slots and variants."""
|
|
218
|
+
out = {}
|
|
219
|
+
for p in sorted(self.root.iterdir()):
|
|
220
|
+
if p.is_dir() and (p / "character.yaml").exists():
|
|
221
|
+
c = self.get(p.name)
|
|
222
|
+
entry: dict[str, object] = {
|
|
223
|
+
"label": c.label,
|
|
224
|
+
"slots": c.slots, # shared across poses
|
|
225
|
+
"default": c.defaults,
|
|
226
|
+
}
|
|
227
|
+
# only advertise poses for genuinely multi-pose characters
|
|
228
|
+
if list(c.poses) != ["default"]:
|
|
229
|
+
entry["default_pose"] = c.default_pose
|
|
230
|
+
entry["poses"] = {
|
|
231
|
+
name: {"slots": pose.slots, "default": pose.defaults}
|
|
232
|
+
for name, pose in c.poses.items()
|
|
233
|
+
}
|
|
234
|
+
out[c.name] = entry
|
|
235
|
+
return out
|
comicforge/pixelart.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Tiny pixel-art helper.
|
|
2
|
+
|
|
3
|
+
A sprite is a list of equal-length strings + a palette {char: color}.
|
|
4
|
+
'.' (or space) = transparent. Returns inner SVG in local coords (cols x rows),
|
|
5
|
+
placed into a panel the same way characters are.
|
|
6
|
+
|
|
7
|
+
Sprites are loaded from a directory of YAML files via :class:`PixelLibrary`.
|
|
8
|
+
Each ``<name>.yaml`` contains ``grid`` (list of strings) and ``palette``
|
|
9
|
+
(dict of char -> color). Inline ``{grid, palette}`` specs also work with no
|
|
10
|
+
library.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PixelLibrary:
|
|
21
|
+
"""Loads pixel-art sprites from ``<root>/<name>.yaml`` lazily, caching."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, root: str | Path):
|
|
24
|
+
self.root = Path(root)
|
|
25
|
+
self._cache: dict[str, dict] = {}
|
|
26
|
+
|
|
27
|
+
def get(self, name: str) -> dict:
|
|
28
|
+
if name in self._cache:
|
|
29
|
+
return self._cache[name]
|
|
30
|
+
path = self.root / f"{name}.yaml"
|
|
31
|
+
if not path.exists():
|
|
32
|
+
avail = sorted(p.stem for p in self.root.glob("*.yaml"))
|
|
33
|
+
raise KeyError(
|
|
34
|
+
f"pixel art '{name}' not found in {self.root}. Available: {avail}"
|
|
35
|
+
)
|
|
36
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
37
|
+
self._cache[name] = data
|
|
38
|
+
return data
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def sprite_svg(grid: list[str], palette: dict[str, str], cell: float = 1.0) -> str:
|
|
42
|
+
rects = []
|
|
43
|
+
for r, row in enumerate(grid):
|
|
44
|
+
for c, ch in enumerate(row):
|
|
45
|
+
if ch in (".", " "):
|
|
46
|
+
continue
|
|
47
|
+
color = palette.get(ch, "#000000")
|
|
48
|
+
rects.append(
|
|
49
|
+
f'<rect x="{c * cell:.2f}" y="{r * cell:.2f}" '
|
|
50
|
+
f'width="{cell:.2f}" height="{cell:.2f}" fill="{color}"/>'
|
|
51
|
+
)
|
|
52
|
+
return f'<g shape-rendering="crispEdges">{"".join(rects)}</g>'
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def dims(grid: list[str]) -> tuple[int, int]:
|
|
56
|
+
return (max(len(r) for r in grid), len(grid))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def resolve(
|
|
60
|
+
spec: dict,
|
|
61
|
+
pixel_library: PixelLibrary | None = None,
|
|
62
|
+
) -> tuple[str, int, int]:
|
|
63
|
+
"""spec: {art: <library name>} OR {grid: [...], palette: {...}}.
|
|
64
|
+
|
|
65
|
+
When ``spec`` has ``"art"``, a ``pixel_library`` must be provided.
|
|
66
|
+
Inline ``{grid, palette}`` works with no library.
|
|
67
|
+
Returns (inner_svg, cols, rows).
|
|
68
|
+
"""
|
|
69
|
+
if "art" in spec:
|
|
70
|
+
if pixel_library is None:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"pixel art '{spec['art']}' requires a pixel library "
|
|
73
|
+
"(pass pixel_library= or set pixel_dir: in the spec)"
|
|
74
|
+
)
|
|
75
|
+
b = pixel_library.get(spec["art"])
|
|
76
|
+
grid, palette = b["grid"], b["palette"]
|
|
77
|
+
else:
|
|
78
|
+
grid, palette = spec["grid"], spec.get("palette", {})
|
|
79
|
+
cols, rows = dims(grid)
|
|
80
|
+
return sprite_svg(grid, palette), cols, rows
|