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/bubbles.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Speech bubbles: speech, thought, and shout, with naive word-wrap + tails.
|
|
2
|
+
|
|
3
|
+
All coordinates here are absolute page px. A bubble is positioned by its centre
|
|
4
|
+
(bx, by); the tail points toward `tail` (also page px).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from xml.sax.saxutils import escape
|
|
11
|
+
|
|
12
|
+
FONT = "DejaVu Sans, Helvetica, Arial, sans-serif"
|
|
13
|
+
INK = "#21304a"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _wrap(text: str, max_chars: int) -> list[str]:
|
|
17
|
+
lines, cur = [], ""
|
|
18
|
+
for word in text.split():
|
|
19
|
+
if cur and len(cur) + 1 + len(word) > max_chars:
|
|
20
|
+
lines.append(cur)
|
|
21
|
+
cur = word
|
|
22
|
+
else:
|
|
23
|
+
cur = f"{cur} {word}".strip()
|
|
24
|
+
if cur:
|
|
25
|
+
lines.append(cur)
|
|
26
|
+
return lines or [""]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _text_block(lines, cx, top, fs, lh):
|
|
30
|
+
spans = []
|
|
31
|
+
for i, ln in enumerate(lines):
|
|
32
|
+
spans.append(
|
|
33
|
+
f'<tspan x="{cx:.1f}" y="{top + fs + i * lh:.1f}">{escape(ln)}</tspan>'
|
|
34
|
+
)
|
|
35
|
+
weight = ""
|
|
36
|
+
return (
|
|
37
|
+
f'<text text-anchor="middle" font-family="{FONT}" '
|
|
38
|
+
f'font-size="{fs}" fill="{INK}" {weight}>{"".join(spans)}</text>'
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def bubble(text, bx, by, tail=None, kind="speech", max_chars=22,
|
|
43
|
+
fs=16, pad=14):
|
|
44
|
+
lines = _wrap(text, max_chars)
|
|
45
|
+
lh = fs * 1.25
|
|
46
|
+
longest = max((len(ln) for ln in lines), default=1)
|
|
47
|
+
w = max(longest * fs * 0.56 + 2 * pad, 60)
|
|
48
|
+
h = len(lines) * lh + 2 * pad
|
|
49
|
+
x, y = bx - w / 2, by - h / 2
|
|
50
|
+
txt = _text_block(lines, bx, y + pad, fs, lh)
|
|
51
|
+
|
|
52
|
+
if kind == "shout":
|
|
53
|
+
body = _burst(x, y, w, h)
|
|
54
|
+
elif kind == "thought":
|
|
55
|
+
rx, ry = w / 2 + 6, h / 2 + 8
|
|
56
|
+
body = (f'<ellipse cx="{bx:.1f}" cy="{by:.1f}" rx="{rx:.1f}" ry="{ry:.1f}" '
|
|
57
|
+
f'fill="#ffffff" stroke="{INK}" stroke-width="3"/>')
|
|
58
|
+
else:
|
|
59
|
+
body = (f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" '
|
|
60
|
+
f'rx="{min(18, h/2):.1f}" fill="#ffffff" stroke="{INK}" '
|
|
61
|
+
f'stroke-width="3"/>')
|
|
62
|
+
|
|
63
|
+
tail_svg = ""
|
|
64
|
+
if tail is not None:
|
|
65
|
+
tail_svg = _tail(bx, by, w, h, tail, kind)
|
|
66
|
+
|
|
67
|
+
return f'<g>{body}{tail_svg}{txt}</g>'
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _tail(bx, by, w, h, tail, kind):
|
|
71
|
+
"""A slim tail that drops from the bubble underside and points toward the
|
|
72
|
+
target — but stops well short of it, so the tip never reaches the figure."""
|
|
73
|
+
tx, ty = tail
|
|
74
|
+
# exit from the bottom edge, nudged horizontally toward the target but kept
|
|
75
|
+
# under the bubble body
|
|
76
|
+
ex = min(max(tx, bx - w * 0.3), bx + w * 0.3)
|
|
77
|
+
ey = by + h / 2
|
|
78
|
+
dx, dy = tx - ex, ty - ey
|
|
79
|
+
dist = math.hypot(dx, dy) or 1.0
|
|
80
|
+
reach = min(dist * 0.45, 46) # capped length keeps the tip off the figure
|
|
81
|
+
ux, uy = dx / dist, dy / dist
|
|
82
|
+
tipx, tipy = ex + ux * reach, ey + uy * reach
|
|
83
|
+
|
|
84
|
+
if kind == "thought":
|
|
85
|
+
dots = ""
|
|
86
|
+
for f in (0.45, 0.74, 1.0):
|
|
87
|
+
r = 6 * (1 - f) + 2.5
|
|
88
|
+
dots += (f'<circle cx="{ex + ux * reach * f:.1f}" '
|
|
89
|
+
f'cy="{ey + uy * reach * f:.1f}" r="{r:.1f}" '
|
|
90
|
+
f'fill="#ffffff" stroke="{INK}" stroke-width="2.5"/>')
|
|
91
|
+
return dots
|
|
92
|
+
# narrow tapered tail for speech/shout
|
|
93
|
+
perp = math.atan2(uy, ux) + math.pi / 2
|
|
94
|
+
base = 6
|
|
95
|
+
ax = ex + math.cos(perp) * base
|
|
96
|
+
ay = ey + math.sin(perp) * base
|
|
97
|
+
bx2 = ex - math.cos(perp) * base
|
|
98
|
+
by2 = ey - math.sin(perp) * base
|
|
99
|
+
return (
|
|
100
|
+
f'<path d="M{ax:.1f} {ay:.1f} L{tipx:.1f} {tipy:.1f} '
|
|
101
|
+
f'L{bx2:.1f} {by2:.1f} Z" fill="#ffffff" stroke="{INK}" '
|
|
102
|
+
f'stroke-width="2.5" stroke-linejoin="round"/>'
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _cloud(x, y, w, h):
|
|
107
|
+
# rounded body + scalloped top edge via overlapping circles
|
|
108
|
+
rx = min(20, h / 2)
|
|
109
|
+
body = (f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" '
|
|
110
|
+
f'rx="{rx:.1f}" fill="#ffffff" stroke="{INK}" stroke-width="3"/>')
|
|
111
|
+
bumps = ""
|
|
112
|
+
n = max(3, int(w // 34))
|
|
113
|
+
for i in range(n):
|
|
114
|
+
cx = x + (i + 0.5) * w / n
|
|
115
|
+
bumps += (f'<circle cx="{cx:.1f}" cy="{y:.1f}" r="13" '
|
|
116
|
+
f'fill="#ffffff" stroke="{INK}" stroke-width="3"/>')
|
|
117
|
+
# mask the inner stroke segments by redrawing body fill on top edge
|
|
118
|
+
cover = (f'<rect x="{x+3:.1f}" y="{y:.1f}" width="{w-6:.1f}" height="14" '
|
|
119
|
+
f'fill="#ffffff" stroke="none"/>')
|
|
120
|
+
return body + bumps + cover + body.replace('fill="#ffffff"', 'fill="none"')
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _burst(x, y, w, h):
|
|
124
|
+
cx, cy = x + w / 2, y + h / 2
|
|
125
|
+
rx, ry = w / 2 + 8, h / 2 + 8
|
|
126
|
+
n = 18
|
|
127
|
+
pts = []
|
|
128
|
+
for i in range(n * 2):
|
|
129
|
+
a = math.pi * i / n
|
|
130
|
+
rr = 1.0 if i % 2 == 0 else 0.78
|
|
131
|
+
pts.append(f"{cx + math.cos(a) * rx * rr:.1f},{cy + math.sin(a) * ry * rr:.1f}")
|
|
132
|
+
return (f'<polygon points="{" ".join(pts)}" fill="#ffffff" '
|
|
133
|
+
f'stroke="{INK}" stroke-width="3" stroke-linejoin="round"/>')
|
comicforge/cli.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""Command line interface.
|
|
2
|
+
|
|
3
|
+
python -m comicforge render examples/pes/pages/slepice.yaml -o out.pdf
|
|
4
|
+
python -m comicforge scene examples/pes/pages/dvur-scene.yaml -o out.png
|
|
5
|
+
python -m comicforge characters --library examples/pes/characters
|
|
6
|
+
python -m comicforge scenes --scenes examples/pes/scenes
|
|
7
|
+
python -m comicforge inspire examples/pes/references.yaml --review
|
|
8
|
+
|
|
9
|
+
When `-o` is omitted, render/scene/panel/character write a timestamped file into
|
|
10
|
+
the gitignored ``output/`` dir (so successive renders accumulate for comparison).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from rich import print as rich_print
|
|
22
|
+
|
|
23
|
+
from .library import Library
|
|
24
|
+
from .pixelart import PixelLibrary
|
|
25
|
+
from .render import (
|
|
26
|
+
render_all_panels,
|
|
27
|
+
render_character,
|
|
28
|
+
render_panel,
|
|
29
|
+
render_scene,
|
|
30
|
+
render_spec,
|
|
31
|
+
)
|
|
32
|
+
from .scene import SceneLibrary
|
|
33
|
+
from .validate import validate_spec
|
|
34
|
+
|
|
35
|
+
# Where renders land when -o is omitted. Gitignored; filenames carry a timestamp
|
|
36
|
+
# so successive renders accumulate and you can watch a page/character evolve.
|
|
37
|
+
OUTPUT_DIR = Path("output")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _stamp() -> str:
|
|
41
|
+
return datetime.now(UTC).astimezone().strftime("%Y%m%d-%H%M%S")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _default_out(stem: str, ext: str = ".png") -> Path:
|
|
45
|
+
"""A timestamped path inside the gitignored output dir."""
|
|
46
|
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
return OUTPUT_DIR / f"{stem}-{_stamp()}{ext}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _parse_selection(char, tokens, pose):
|
|
51
|
+
"""Turn CLI tokens into a (selection, pose) pair for one character.
|
|
52
|
+
|
|
53
|
+
A token is either ``key=value`` (``pose=walk``, ``face=happy``) or a bare
|
|
54
|
+
name that is matched against the character's poses, then its slot variants.
|
|
55
|
+
"""
|
|
56
|
+
# pass 1: settle the pose first, so variant lookup uses the right slots
|
|
57
|
+
for tok in tokens:
|
|
58
|
+
if "=" in tok:
|
|
59
|
+
k, v = tok.split("=", 1)
|
|
60
|
+
if k == "pose":
|
|
61
|
+
pose = v
|
|
62
|
+
elif tok in char.poses:
|
|
63
|
+
pose = tok
|
|
64
|
+
|
|
65
|
+
slots = char.slots_for(pose)
|
|
66
|
+
selection: dict[str, str] = {}
|
|
67
|
+
for tok in tokens:
|
|
68
|
+
if "=" in tok:
|
|
69
|
+
k, v = tok.split("=", 1)
|
|
70
|
+
if k != "pose":
|
|
71
|
+
selection[k] = v
|
|
72
|
+
elif tok in char.poses:
|
|
73
|
+
continue
|
|
74
|
+
else:
|
|
75
|
+
slot = next((s for s, variants in slots.items() if tok in variants), None)
|
|
76
|
+
if slot is None:
|
|
77
|
+
raise SystemExit(
|
|
78
|
+
f"don't know '{tok}' for {char.name}: "
|
|
79
|
+
f"not a pose {list(char.poses)} nor a variant in {slots}"
|
|
80
|
+
)
|
|
81
|
+
selection[slot] = tok
|
|
82
|
+
return selection, pose
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main(argv=None): # noqa: PLR0912, PLR0915 — flat CLI dispatcher; clearer linear
|
|
86
|
+
p = argparse.ArgumentParser(prog="comicforge")
|
|
87
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
88
|
+
|
|
89
|
+
init = sub.add_parser(
|
|
90
|
+
"init", help="scaffold a new data-only project (no Python needed)"
|
|
91
|
+
)
|
|
92
|
+
init.add_argument(
|
|
93
|
+
"dest", type=Path, help="directory to create the project in (e.g. ./my-comic)"
|
|
94
|
+
)
|
|
95
|
+
init.add_argument(
|
|
96
|
+
"--force",
|
|
97
|
+
action="store_true",
|
|
98
|
+
help="overwrite existing files (default: skip files that already exist)",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
r = sub.add_parser("render", help="render a comic page spec")
|
|
102
|
+
r.add_argument("spec", type=Path)
|
|
103
|
+
r.add_argument(
|
|
104
|
+
"-o",
|
|
105
|
+
"--out",
|
|
106
|
+
type=Path,
|
|
107
|
+
default=None,
|
|
108
|
+
help="output .svg / .png / .pdf (default: output/<spec>-<timestamp>.png)",
|
|
109
|
+
)
|
|
110
|
+
r.add_argument("--library", type=Path, default=None)
|
|
111
|
+
r.add_argument("--scenes", type=Path, default=None)
|
|
112
|
+
r.add_argument(
|
|
113
|
+
"--pixel-dir",
|
|
114
|
+
type=Path,
|
|
115
|
+
default=None,
|
|
116
|
+
dest="pixel_dir",
|
|
117
|
+
help="pixel-art sprite library directory",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
v = sub.add_parser(
|
|
121
|
+
"validate", help="check a page/scene spec against its libraries (no render)"
|
|
122
|
+
)
|
|
123
|
+
v.add_argument("spec", type=Path)
|
|
124
|
+
v.add_argument("--library", type=Path, default=None)
|
|
125
|
+
v.add_argument("--scenes", type=Path, default=None)
|
|
126
|
+
v.add_argument(
|
|
127
|
+
"--pixel-dir",
|
|
128
|
+
type=Path,
|
|
129
|
+
default=None,
|
|
130
|
+
dest="pixel_dir",
|
|
131
|
+
help="pixel-art sprite library directory",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
s = sub.add_parser("scene", help="render a standalone scene illustration")
|
|
135
|
+
s.add_argument("spec", type=Path)
|
|
136
|
+
s.add_argument(
|
|
137
|
+
"-o",
|
|
138
|
+
"--out",
|
|
139
|
+
type=Path,
|
|
140
|
+
default=None,
|
|
141
|
+
help="output .svg / .png / .pdf (default: output/<spec>-<timestamp>.png)",
|
|
142
|
+
)
|
|
143
|
+
s.add_argument("--library", type=Path, default=None)
|
|
144
|
+
s.add_argument("--scenes", type=Path, default=None)
|
|
145
|
+
s.add_argument(
|
|
146
|
+
"--pixel-dir",
|
|
147
|
+
type=Path,
|
|
148
|
+
default=None,
|
|
149
|
+
dest="pixel_dir",
|
|
150
|
+
help="pixel-art sprite library directory",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
pn = sub.add_parser(
|
|
154
|
+
"panel", help="render individual panel(s) at low res for review"
|
|
155
|
+
)
|
|
156
|
+
pn.add_argument("spec", type=Path)
|
|
157
|
+
pn.add_argument(
|
|
158
|
+
"-o",
|
|
159
|
+
"--out",
|
|
160
|
+
type=Path,
|
|
161
|
+
default=None,
|
|
162
|
+
help="output file, or a directory when --all "
|
|
163
|
+
"(default: timestamped path under output/)",
|
|
164
|
+
)
|
|
165
|
+
pn.add_argument("--row", type=int, default=0)
|
|
166
|
+
pn.add_argument("--col", type=int, default=0)
|
|
167
|
+
pn.add_argument(
|
|
168
|
+
"--all", action="store_true", help="render every panel into the -o directory"
|
|
169
|
+
)
|
|
170
|
+
pn.add_argument(
|
|
171
|
+
"--scale",
|
|
172
|
+
type=float,
|
|
173
|
+
default=0.5,
|
|
174
|
+
help="size vs full-page panel (default 0.5 = low res)",
|
|
175
|
+
)
|
|
176
|
+
pn.add_argument("--library", type=Path, default=None)
|
|
177
|
+
pn.add_argument("--scenes", type=Path, default=None)
|
|
178
|
+
pn.add_argument(
|
|
179
|
+
"--pixel-dir",
|
|
180
|
+
type=Path,
|
|
181
|
+
default=None,
|
|
182
|
+
dest="pixel_dir",
|
|
183
|
+
help="pixel-art sprite library directory",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
ch = sub.add_parser(
|
|
187
|
+
"character", help="render a single character on its own, for review"
|
|
188
|
+
)
|
|
189
|
+
ch.add_argument("name", help="character to render")
|
|
190
|
+
ch.add_argument(
|
|
191
|
+
"selections",
|
|
192
|
+
nargs="*",
|
|
193
|
+
help="pose and slot variants as bare names (sit, happy) or key=value "
|
|
194
|
+
"(face=happy, pose=walk)",
|
|
195
|
+
)
|
|
196
|
+
ch.add_argument(
|
|
197
|
+
"-o",
|
|
198
|
+
"--out",
|
|
199
|
+
type=Path,
|
|
200
|
+
default=None,
|
|
201
|
+
help="output .svg / .png / .pdf (default: output/<name>-<timestamp>.png)",
|
|
202
|
+
)
|
|
203
|
+
ch.add_argument(
|
|
204
|
+
"--library", type=Path, required=True, help="character library directory"
|
|
205
|
+
)
|
|
206
|
+
ch.add_argument(
|
|
207
|
+
"--pose", default=None, help="pose to render (default: character's)"
|
|
208
|
+
)
|
|
209
|
+
ch.add_argument(
|
|
210
|
+
"--scale", type=float, default=2.0, help="px per viewBox unit (default 2)"
|
|
211
|
+
)
|
|
212
|
+
ch.add_argument("--bg", default="#ffffff", help="canvas background colour")
|
|
213
|
+
ch.add_argument("--flip", action="store_true", help="mirror horizontally")
|
|
214
|
+
ch.add_argument(
|
|
215
|
+
"--thumb-px",
|
|
216
|
+
type=int,
|
|
217
|
+
default=320,
|
|
218
|
+
dest="thumb_px",
|
|
219
|
+
help="body width of the small companion PNG (0 to skip it)",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
c = sub.add_parser("characters", help="print the character library contract")
|
|
223
|
+
c.add_argument(
|
|
224
|
+
"--library",
|
|
225
|
+
type=Path,
|
|
226
|
+
required=True,
|
|
227
|
+
help="character library directory (required)",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
sc = sub.add_parser("scenes", help="print the scene library contract")
|
|
231
|
+
sc.add_argument(
|
|
232
|
+
"--scenes", type=Path, required=True, help="scenes directory (required)"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
ins = sub.add_parser(
|
|
236
|
+
"inspire", help="generate reference images from a theme + descriptions"
|
|
237
|
+
)
|
|
238
|
+
ins.add_argument("references", type=Path, help="references spec (list of items)")
|
|
239
|
+
ins.add_argument(
|
|
240
|
+
"-o",
|
|
241
|
+
"--out",
|
|
242
|
+
type=Path,
|
|
243
|
+
default=None,
|
|
244
|
+
help="output dir (default: references/ next to the spec)",
|
|
245
|
+
)
|
|
246
|
+
ins.add_argument(
|
|
247
|
+
"--theme",
|
|
248
|
+
type=Path,
|
|
249
|
+
default=None,
|
|
250
|
+
help="theme.yaml (default: sibling of the spec)",
|
|
251
|
+
)
|
|
252
|
+
ins.add_argument(
|
|
253
|
+
"--only",
|
|
254
|
+
default=None,
|
|
255
|
+
help="generate only these ids (comma-separated)",
|
|
256
|
+
)
|
|
257
|
+
ins.add_argument(
|
|
258
|
+
"--force", action="store_true", help="regenerate even if a .png exists"
|
|
259
|
+
)
|
|
260
|
+
ins.add_argument(
|
|
261
|
+
"--dry-run",
|
|
262
|
+
action="store_true",
|
|
263
|
+
help="write composed prompts only; no API call, no token needed",
|
|
264
|
+
)
|
|
265
|
+
ins.add_argument(
|
|
266
|
+
"--review", action="store_true", help="also write a review.html grid"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
args = p.parse_args(argv)
|
|
270
|
+
|
|
271
|
+
if args.cmd == "init":
|
|
272
|
+
from .scaffold import init_project # noqa: PLC0415 — keep import local
|
|
273
|
+
|
|
274
|
+
created = init_project(args.dest, force=args.force)
|
|
275
|
+
for f in created:
|
|
276
|
+
rich_print(f"created {f}")
|
|
277
|
+
if not created:
|
|
278
|
+
rich_print(f"{args.dest}: nothing to do (all files exist; use --force)")
|
|
279
|
+
else:
|
|
280
|
+
rich_print(
|
|
281
|
+
f"\nProject ready in {args.dest}. Next:\n"
|
|
282
|
+
f" cmf render {args.dest / 'pages' / 'hello.yaml'}"
|
|
283
|
+
)
|
|
284
|
+
elif args.cmd in ("render", "scene"):
|
|
285
|
+
lib = Library(args.library) if args.library else None
|
|
286
|
+
scenes = SceneLibrary(args.scenes) if args.scenes else None
|
|
287
|
+
px = PixelLibrary(args.pixel_dir) if args.pixel_dir else None
|
|
288
|
+
fn = render_spec if args.cmd == "render" else render_scene
|
|
289
|
+
out = args.out or _default_out(args.spec.stem)
|
|
290
|
+
try:
|
|
291
|
+
fn(args.spec, out, library=lib, scenes=scenes, pixel_library=px)
|
|
292
|
+
except ValueError as e:
|
|
293
|
+
rich_print(f"{args.spec}: {e}")
|
|
294
|
+
return 1
|
|
295
|
+
rich_print(f"wrote {out}")
|
|
296
|
+
elif args.cmd == "validate":
|
|
297
|
+
lib = Library(args.library) if args.library else None
|
|
298
|
+
scenes = SceneLibrary(args.scenes) if args.scenes else None
|
|
299
|
+
px = PixelLibrary(args.pixel_dir) if args.pixel_dir else None
|
|
300
|
+
problems = validate_spec(
|
|
301
|
+
args.spec, library=lib, scenes=scenes, pixel_library=px
|
|
302
|
+
)
|
|
303
|
+
if problems:
|
|
304
|
+
rich_print(f"{args.spec}: {len(problems)} problem(s)")
|
|
305
|
+
for msg in problems:
|
|
306
|
+
rich_print(f" - {msg}")
|
|
307
|
+
return 1
|
|
308
|
+
rich_print(f"{args.spec}: ok")
|
|
309
|
+
return 0
|
|
310
|
+
elif args.cmd == "panel":
|
|
311
|
+
lib = Library(args.library) if args.library else None
|
|
312
|
+
scenes = SceneLibrary(args.scenes) if args.scenes else None
|
|
313
|
+
px = PixelLibrary(args.pixel_dir) if args.pixel_dir else None
|
|
314
|
+
if args.all:
|
|
315
|
+
out = args.out or (OUTPUT_DIR / f"{args.spec.stem}-panels-{_stamp()}")
|
|
316
|
+
for o in render_all_panels(
|
|
317
|
+
args.spec,
|
|
318
|
+
out,
|
|
319
|
+
library=lib,
|
|
320
|
+
scenes=scenes,
|
|
321
|
+
pixel_library=px,
|
|
322
|
+
scale=args.scale,
|
|
323
|
+
):
|
|
324
|
+
rich_print(f"wrote {o}")
|
|
325
|
+
else:
|
|
326
|
+
out = args.out or _default_out(
|
|
327
|
+
f"{args.spec.stem}-r{args.row}c{args.col}"
|
|
328
|
+
)
|
|
329
|
+
render_panel(
|
|
330
|
+
args.spec,
|
|
331
|
+
out,
|
|
332
|
+
args.row,
|
|
333
|
+
args.col,
|
|
334
|
+
library=lib,
|
|
335
|
+
scenes=scenes,
|
|
336
|
+
scale=args.scale,
|
|
337
|
+
pixel_library=px,
|
|
338
|
+
)
|
|
339
|
+
rich_print(f"wrote {out}")
|
|
340
|
+
elif args.cmd == "character":
|
|
341
|
+
lib = Library(args.library)
|
|
342
|
+
char = lib.get(args.name)
|
|
343
|
+
selection, pose = _parse_selection(char, args.selections, args.pose)
|
|
344
|
+
out = args.out or _default_out(args.name)
|
|
345
|
+
render_character(
|
|
346
|
+
args.name,
|
|
347
|
+
out,
|
|
348
|
+
selection,
|
|
349
|
+
pose=pose,
|
|
350
|
+
library=lib,
|
|
351
|
+
scale=args.scale,
|
|
352
|
+
bg=args.bg,
|
|
353
|
+
flip=args.flip,
|
|
354
|
+
)
|
|
355
|
+
rich_print(f"wrote {out}")
|
|
356
|
+
# a smaller PNG alongside it, sized for Claude to read with fewer tokens
|
|
357
|
+
if args.thumb_px > 0:
|
|
358
|
+
thumb = out.with_name(f"{out.stem}.small.png")
|
|
359
|
+
thumb_scale = min(args.scale, args.thumb_px / char.resolve_pose(pose).w)
|
|
360
|
+
render_character(
|
|
361
|
+
args.name,
|
|
362
|
+
thumb,
|
|
363
|
+
selection,
|
|
364
|
+
pose=pose,
|
|
365
|
+
library=lib,
|
|
366
|
+
scale=thumb_scale,
|
|
367
|
+
bg=args.bg,
|
|
368
|
+
flip=args.flip,
|
|
369
|
+
)
|
|
370
|
+
rich_print(f"wrote {thumb}")
|
|
371
|
+
elif args.cmd == "characters":
|
|
372
|
+
lib = Library(args.library)
|
|
373
|
+
json.dump(lib.manifest(), sys.stdout, indent=2, ensure_ascii=False)
|
|
374
|
+
rich_print()
|
|
375
|
+
elif args.cmd == "scenes":
|
|
376
|
+
scenes = SceneLibrary(args.scenes)
|
|
377
|
+
json.dump(scenes.manifest(), sys.stdout, indent=2, ensure_ascii=False)
|
|
378
|
+
rich_print()
|
|
379
|
+
elif args.cmd == "inspire":
|
|
380
|
+
from .inspire import generate # noqa: PLC0415 — lazy: keeps Replicate optional
|
|
381
|
+
|
|
382
|
+
only = {s.strip() for s in args.only.split(",")} if args.only else None
|
|
383
|
+
out = generate(
|
|
384
|
+
args.references,
|
|
385
|
+
args.out,
|
|
386
|
+
theme_path=args.theme,
|
|
387
|
+
only=only,
|
|
388
|
+
force=args.force,
|
|
389
|
+
review=args.review,
|
|
390
|
+
dry_run=args.dry_run,
|
|
391
|
+
)
|
|
392
|
+
rich_print(f"wrote {len(out)} file(s)")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
if __name__ == "__main__":
|
|
396
|
+
main()
|