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/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