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/scene.py ADDED
@@ -0,0 +1,112 @@
1
+ """Load the scene library and compose a scene background.
2
+
3
+ A scene is a directory mirroring a character:
4
+ base.svg, <slot>-<variant>.svg, scene.yaml
5
+
6
+ It uses the same base + stackable-overlay model as characters (see
7
+ :mod:`comicforge.library`), but a scene is always a single body (no poses) and
8
+ instead of being placed at a height it is scaled to *cover* a panel (or the
9
+ whole canvas, for a standalone illustration).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ import yaml
18
+
19
+ from .library import _inner
20
+
21
+
22
+ @dataclass
23
+ class Scene:
24
+ """A background: a base body plus stackable overlay variants, one viewBox."""
25
+
26
+ name: str
27
+ label: str
28
+ w: float
29
+ h: float
30
+ slots: dict[str, list[str]]
31
+ defaults: dict[str, str]
32
+ base: str
33
+ variants: dict[str, str] = field(default_factory=dict)
34
+
35
+ def compose_inner(self, selection: dict[str, str]) -> str:
36
+ parts = [self.base]
37
+ for slot in self.slots: # manifest order = draw order
38
+ variant = selection.get(slot, self.defaults.get(slot))
39
+ if variant is None:
40
+ continue
41
+ if variant not in self.slots[slot]:
42
+ raise ValueError(
43
+ f"{self.name}: slot '{slot}' has no variant '{variant}'. "
44
+ f"Available: {self.slots[slot]}"
45
+ )
46
+ parts.append(self.variants[f"{slot}-{variant}"])
47
+ return "\n".join(parts)
48
+
49
+
50
+ class SceneLibrary:
51
+ """Loads scenes from ``<root>/<name>/`` lazily, caching parsed scenes."""
52
+
53
+ def __init__(self, root: str | Path):
54
+ self.root = Path(root)
55
+ self._cache: dict[str, Scene] = {}
56
+
57
+ def get(self, name: str) -> Scene:
58
+ if name in self._cache:
59
+ return self._cache[name]
60
+ sdir = self.root / name
61
+ if not sdir.is_dir():
62
+ avail = (
63
+ [p.name for p in self.root.iterdir() if p.is_dir()]
64
+ if self.root.is_dir()
65
+ else []
66
+ )
67
+ raise KeyError(f"scene '{name}' not found in {self.root}. Have: {avail}")
68
+ man = yaml.safe_load((sdir / "scene.yaml").read_text(encoding="utf-8"))
69
+ w, h = man["viewbox"]
70
+ slots = man.get("slots", {})
71
+ variants = {}
72
+ for slot, names in slots.items():
73
+ for v in names:
74
+ variants[f"{slot}-{v}"] = _inner(sdir / f"{slot}-{v}.svg")
75
+ scene = Scene(
76
+ name=man["name"],
77
+ label=man.get("label", man["name"]),
78
+ w=w,
79
+ h=h,
80
+ slots=slots,
81
+ defaults=man.get("default", {}),
82
+ base=_inner(sdir / "base.svg"),
83
+ variants=variants,
84
+ )
85
+ self._cache[name] = scene
86
+ return scene
87
+
88
+ def manifest(self) -> dict:
89
+ """Machine-readable contract: every scene, its slots and variants."""
90
+ out = {}
91
+ if not self.root.is_dir():
92
+ return out
93
+ for p in sorted(self.root.iterdir()):
94
+ if p.is_dir() and (p / "scene.yaml").exists():
95
+ c = self.get(p.name)
96
+ out[c.name] = {
97
+ "label": c.label,
98
+ "slots": c.slots,
99
+ "default": c.defaults,
100
+ }
101
+ return out
102
+
103
+
104
+ def cover(scene: Scene, selection, px, py, pw, ph) -> str:
105
+ """Place a scene so it fully covers the (px, py, pw, ph) box, centred."""
106
+ s = max(pw / scene.w, ph / scene.h)
107
+ ox = px + (pw - scene.w * s) / 2
108
+ oy = py + (ph - scene.h * s) / 2
109
+ return (
110
+ f'<g transform="translate({ox:.2f},{oy:.2f}) scale({s:.4f})">\n'
111
+ f"{scene.compose_inner(selection)}\n</g>"
112
+ )
comicforge/validate.py ADDED
@@ -0,0 +1,192 @@
1
+ """Statically check a page / scene spec against the libraries it points at.
2
+
3
+ ``render`` only fails on the *first* genuinely unusable thing (a missing
4
+ character, a bad variant) and **silently ignores** keys it doesn't recognise —
5
+ so a typo like ``fcae: happy`` or ``post: walk`` renders a default-faced,
6
+ default-posed actor with no error at all. ``validate`` walks the whole spec
7
+ without drawing anything, collecting *every* problem at once:
8
+
9
+ - characters / scenes / pixel sprites that don't exist in the library
10
+ - poses an actor asks for that the character doesn't have
11
+ - slot variants that don't exist for the chosen character+pose / scene
12
+ - actor / scene keys that aren't reserved and aren't a real slot (likely typos)
13
+ - bubble ``speaker`` that names no actor in the panel, unknown bubble ``kind``
14
+ - structural holes (no ``rows``, a row without ``panels``, a bubble with no text)
15
+
16
+ It returns a list of human-readable problem strings (empty == the spec is sound).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from pathlib import Path
22
+
23
+ from .library import Library
24
+ from .pixelart import PixelLibrary
25
+ from .render import _as_list, _build_libs, load_spec, spec_type
26
+ from .scene import SceneLibrary
27
+
28
+ # keys on an actor / scene dict that are positioning or identity, not slots
29
+ _ACTOR_RESERVED = {"char", "pose", "x", "y", "scale", "flip"}
30
+ _SCENE_RESERVED = {"name"}
31
+ _BUBBLE_KINDS = {"speech", "thought", "shout"}
32
+
33
+
34
+ def _check_actor(actor: dict, lib: Library, where: str, problems: list[str]) -> None:
35
+ name = actor.get("char")
36
+ if not name:
37
+ problems.append(f"{where}: actor with no 'char'")
38
+ return
39
+ try:
40
+ char = lib.get(name)
41
+ except KeyError as e:
42
+ problems.append(f"{where}: {e}")
43
+ return
44
+
45
+ pose = actor.get("pose")
46
+ if pose is not None and pose not in char.poses:
47
+ problems.append(
48
+ f"{where}: {name} has no pose '{pose}'. Have: {list(char.poses)}"
49
+ )
50
+ pose = None # fall back so slot checks still run against a real pose
51
+
52
+ slots = char.slots_for(pose)
53
+ for key, value in actor.items():
54
+ if key in _ACTOR_RESERVED:
55
+ continue
56
+ if key not in slots:
57
+ problems.append(
58
+ f"{where}: {name} has no slot '{key}' "
59
+ f"(ignored when rendering). Slots: {sorted(slots)}"
60
+ )
61
+ elif value not in slots[key]:
62
+ problems.append(
63
+ f"{where}: {name} slot '{key}' has no variant '{value}'. "
64
+ f"Have: {slots[key]}"
65
+ )
66
+
67
+
68
+ def _check_scene(sc, scenes, where: str, problems: list[str]) -> None:
69
+ sc = {"name": sc} if isinstance(sc, str) else sc
70
+ name = sc.get("name")
71
+ if not name:
72
+ problems.append(f"{where}: scene with no 'name'")
73
+ return
74
+ try:
75
+ scene = scenes.get(name)
76
+ except KeyError as e:
77
+ problems.append(f"{where}: {e}")
78
+ return
79
+ for key, value in sc.items():
80
+ if key in _SCENE_RESERVED:
81
+ continue
82
+ if key not in scene.slots:
83
+ problems.append(
84
+ f"{where}: scene '{name}' has no slot '{key}' "
85
+ f"(ignored when rendering). Slots: {sorted(scene.slots)}"
86
+ )
87
+ elif value not in scene.slots[key]:
88
+ problems.append(
89
+ f"{where}: scene '{name}' slot '{key}' has no variant '{value}'. "
90
+ f"Have: {scene.slots[key]}"
91
+ )
92
+
93
+
94
+ def _check_pixel(spec, pxlib, where: str, problems: list[str]) -> None:
95
+ if "art" in spec:
96
+ if pxlib is None:
97
+ problems.append(
98
+ f"{where}: pixel art '{spec['art']}' needs a pixel library "
99
+ "(set 'pixel_dir:' in the spec or pass --pixel-dir)"
100
+ )
101
+ return
102
+ try:
103
+ pxlib.get(spec["art"])
104
+ except KeyError as e:
105
+ problems.append(f"{where}: {e}")
106
+ elif "grid" not in spec:
107
+ problems.append(f"{where}: pixel entry has neither 'art' nor 'grid'")
108
+
109
+
110
+ def _check_bubbles(panel: dict, where: str, problems: list[str]) -> None:
111
+ speakers = {a.get("char") for a in panel.get("actors", [])}
112
+ for b in panel.get("bubbles", []):
113
+ if not b.get("text"):
114
+ problems.append(f"{where}: bubble with no 'text'")
115
+ kind = b.get("kind", "speech")
116
+ if kind not in _BUBBLE_KINDS:
117
+ problems.append(
118
+ f"{where}: bubble kind '{kind}' unknown. Have: {sorted(_BUBBLE_KINDS)}"
119
+ )
120
+ speaker = b.get("speaker")
121
+ if speaker is not None and speaker not in speakers:
122
+ problems.append(
123
+ f"{where}: bubble speaker '{speaker}' is not an actor here. "
124
+ f"Actors: {sorted(s for s in speakers if s)}"
125
+ )
126
+
127
+
128
+ def _check_panel(panel, libs, where, problems) -> None:
129
+ lib, scenes, pxlib = libs
130
+ if panel.get("scene") is not None:
131
+ _check_scene(panel["scene"], scenes, where, problems)
132
+ for spec in _as_list(panel.get("pixel")):
133
+ _check_pixel(spec, pxlib, where, problems)
134
+ for actor in panel.get("actors", []):
135
+ _check_actor(actor, lib, where, problems)
136
+ _check_bubbles(panel, where, problems)
137
+
138
+
139
+ def validate_spec(
140
+ spec,
141
+ library: Library | None = None,
142
+ scenes: SceneLibrary | None = None,
143
+ pixel_library: PixelLibrary | None = None,
144
+ ) -> list[str]:
145
+ """Return every problem found in *spec* (path or dict). Empty == sound.
146
+
147
+ A page spec has ``rows`` of ``panels``; a standalone scene spec has a
148
+ top-level ``scene`` and acts as a single panel. Both are validated.
149
+ """
150
+ spec_dir = None
151
+ if not isinstance(spec, dict):
152
+ spec_path = Path(spec)
153
+ spec_dir = spec_path.parent.resolve()
154
+ spec = load_spec(spec_path)
155
+
156
+ problems: list[str] = []
157
+ try:
158
+ libs = _build_libs(spec, spec_dir, library, scenes, pixel_library)
159
+ except ValueError as e:
160
+ # a missing library dir is itself the headline problem; nothing else to check
161
+ return [str(e)]
162
+
163
+ try:
164
+ kind = spec_type(spec)
165
+ except ValueError as e:
166
+ return [str(e)]
167
+
168
+ if kind == "scene": # standalone illustration, rendered with `scene`
169
+ if "scene" not in spec:
170
+ problems.append("scene spec has no top-level 'scene'")
171
+ if "rows" in spec:
172
+ problems.append(
173
+ "scene spec must not have 'rows' (use a 'page' spec for a grid)"
174
+ )
175
+ _check_panel(spec, libs, "scene", problems)
176
+ return problems
177
+
178
+ rows = spec.get("rows")
179
+ if not rows:
180
+ problems.append(
181
+ "page spec has no 'rows'"
182
+ + (" (did you mean type: scene?)" if "scene" in spec else "")
183
+ )
184
+ return problems
185
+ for ri, row in enumerate(rows):
186
+ panels = row.get("panels")
187
+ if not panels:
188
+ problems.append(f"r{ri}: row has no 'panels'")
189
+ continue
190
+ for ci, panel in enumerate(panels):
191
+ _check_panel(panel, libs, f"r{ri}c{ci}", problems)
192
+ return problems
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: comicforge
3
+ Version: 0.0.1
4
+ Summary: A tiny, scriptable comic-page engine — author comics as YAML, render to SVG / PNG / PDF
5
+ Requires-Python: >=3.13
6
+ Requires-Dist: cairosvg>=2.7
7
+ Requires-Dist: pyyaml>=6.0
8
+ Requires-Dist: rich>=13.0
9
+ Provides-Extra: inspire
10
+ Requires-Dist: python-dotenv>=1.0; extra == 'inspire'
11
+ Requires-Dist: replicate>=1.0; extra == 'inspire'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # ComicForge
15
+
16
+ A tiny, scriptable comic-page engine. Characters are a **base SVG + stackable
17
+ variant overlays** (faces, arms, …); a comic page is a **declarative YAML spec**;
18
+ output is **SVG / PNG / PDF**. Built so an LLM (or you) can author pages as plain
19
+ text — see the authoring skill [`skills/comicforge/SKILL.md`](skills/comicforge/SKILL.md)
20
+ for the full authoring contract and [`skills/comicforge/reference.md`](skills/comicforge/reference.md)
21
+ for a deeper reference. Working **on the engine** instead? See [`CLAUDE.md`](CLAUDE.md).
22
+
23
+ ## Start a project
24
+
25
+ ComicForge ships no art and a project needs no Python — a project is just YAML
26
+ specs + SVG / pixel art that the CLI points at. Install the engine globally and
27
+ scaffold one:
28
+
29
+ ```bash
30
+ uv tool install comicforge # puts `cmf` on your PATH (no venv needed)
31
+ cmf init my-comic # scaffold characters/ scenes/ pixel/ pages/ + skill
32
+ cd my-comic && cmf render pages/hello.yaml
33
+ ```
34
+
35
+ See [`docs/starting-a-project.md`](docs/starting-a-project.md) for the data-only
36
+ setup, version pinning, and when to make it a real Python project.
37
+
38
+ ## Working on the engine
39
+
40
+ ```bash
41
+ uv sync # create .venv and install deps
42
+ ```
43
+
44
+ ## Use
45
+
46
+ ```bash
47
+ # render a comic page
48
+ cmf render examples/pes/pages/slepice.yaml -o slepice.png
49
+ cmf render examples/pes/pages/slepice.yaml -o slepice.pdf
50
+
51
+ # render a standalone scene illustration (one background, no comic grid)
52
+ cmf scene examples/pes/pages/dvur-scene.yaml -o dvur.png
53
+
54
+ # render individual panels at low res for quick review
55
+ cmf panel examples/pes/pages/slepice.yaml -o panel.png --row 0 --col 0
56
+ cmf panel examples/pes/pages/slepice.yaml -o panels/ --all --scale 0.5
57
+
58
+ # print the machine-readable contracts (characters / scenes, slots, variants)
59
+ cmf characters --library examples/pes/characters
60
+ cmf scenes --scenes examples/pes/scenes
61
+ ```
62
+
63
+ Or from Python:
64
+ ```python
65
+ from comicforge import render_spec
66
+ render_spec("examples/pes/pages/slepice.yaml", "out.pdf")
67
+ ```
68
+
69
+ ## How it fits together
70
+
71
+ ComicForge is a **content-free engine** plus **self-contained projects**. There is
72
+ no shared asset library: every project owns all of its own art. A real downstream
73
+ project just does `pip install comicforge` and brings its own characters — none of
74
+ the example art comes with the engine.
75
+
76
+ | Layer | Path | Purpose |
77
+ |---|---|---|
78
+ | Engine | `comicforge/` | Pure code — loads assets, composes SVG, writes output. No hardcoded content. |
79
+ | Project | `examples/<name>/` | A self-contained comic: its own characters, scenes, pixel art, and page specs. |
80
+
81
+ A project is one directory with four asset folders:
82
+
83
+ ```
84
+ examples/pes/
85
+ characters/tom/ flat: base.svg + <slot>-<variant>.svg + character.yaml
86
+ characters/bara/ posed: shared faces + character.yaml + poses/{sit,walk}/
87
+ scenes/{dvur,pokoj}/ base.svg + <slot>-<variant>.svg + scene.yaml
88
+ pixel/{heart,sun,...}.yaml grid + palette
89
+ pages/
90
+ slepice.yaml 2×2 comic — Tom and Bára watch chickens
91
+ kosticka.yaml Strip with scenes, pixel art, multiple bubble types
92
+ dvur-scene.yaml Standalone illustration (use `comicforge scene`)
93
+
94
+ comicforge/library.py loads a character, stacks base+overlays, places it
95
+ comicforge/scene.py loads a scene, stacks overlays, scales it to cover a box
96
+ comicforge/bubbles.py speech / thought / shout bubbles with word-wrap + tail
97
+ comicforge/pixelart.py grid+palette → SVG sprite
98
+ comicforge/render.py row/panel pages + standalone scenes → SVG → PNG/PDF
99
+ comicforge/cli.py `render`, `scene`, `panel`, `characters`, `scenes` commands
100
+ ```
101
+
102
+ ### Asset directories
103
+
104
+ Each spec declares where its assets live via top-level keys (paths are relative to
105
+ the spec file's directory). For a spec under `pages/`, the sibling asset folders
106
+ are one level up:
107
+
108
+ ```yaml
109
+ library: "../characters" # character art
110
+ scenes_dir: "../scenes" # scene backgrounds
111
+ pixel_dir: "../pixel" # pixel-art sprites
112
+ ```
113
+
114
+ CLI flags (`--library`, `--scenes`, `--pixel-dir`) override spec keys and are
115
+ treated as relative to the current working directory.
116
+
117
+ See [`skills/comicforge/reference.md`](skills/comicforge/reference.md) for the full path
118
+ resolution rule, how to add characters/scenes/sprites, and the complete CLI reference.
119
+
120
+ ## Design choices
121
+
122
+ - **Base + overlays** in one shared local canvas — "posing" is just choosing which
123
+ overlay SVGs to stack. No rig math; each pose/expression is a small SVG you can
124
+ edit by hand.
125
+ - **Declarative spec** — the whole agent surface is YAML + the `characters` JSON.
126
+ - **SVG all the way down**, rasterized only at the end — crisp vector PDF for print.
127
+ - **Content-free engine** — `comicforge/` ships no art. Each project owns all its
128
+ own assets, so the engine is a dependency, not a content bundle.
129
+
130
+ ## Roadmap ideas
131
+
132
+ - A FastAPI + HTMX preview server (live re-render on spec edit).
133
+ - An MCP server exposing `list_characters` / `render_page` as tools.
134
+ - Part-anchored slots (separate eyes/mouth/limb pivots) for finer posing.
135
+ - Free-form panel shapes and a gutter/layout DSL.
136
+ - AI-image panels (drop a generated raster into a panel like pixel art).
@@ -0,0 +1,17 @@
1
+ comicforge/__init__.py,sha256=YP6JJ-C6JXrSgKRaYKkFW7pPSsKjl1CnsKXUhwoGN3Q,334
2
+ comicforge/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
3
+ comicforge/bubbles.py,sha256=BxjBvvbKG_F0ZvujwSvNPjDZIOcKQOKg8PAC4Cwi8Vk,4727
4
+ comicforge/cli.py,sha256=XTVDJZQb0b1yLQHcEJr7mX8md2q2tIcQmWGY9SQ6dWg,13125
5
+ comicforge/inspire.py,sha256=uVBhhNIFvfh9wdhFs4uqPDyu9Yh2dt3dQliM6LPa-lk,9089
6
+ comicforge/library.py,sha256=SdR7qtju_JArhInbbqrypP1i5mifR7QyRl1mnDo-mmo,9124
7
+ comicforge/pixelart.py,sha256=ZwvAffhvTZzI0Qm6YtE2dBliMrTtAd6jyJD_BqjRalg,2697
8
+ comicforge/render.py,sha256=9tyHIDlBvFW-JzwP8Qua4_SWrjmrf4kOmOF3P3vVGBM,18839
9
+ comicforge/scaffold.py,sha256=kk0Cqh6ipju5gJFeHtBuDvnsoNDr5iu2jWxzpt745ms,4826
10
+ comicforge/scene.py,sha256=F_R50iyPEera4Qrm1UTVnSCRxu6swTvBDRia-e0CBcQ,3683
11
+ comicforge/validate.py,sha256=0JP3_zXF2SG86guTdKUX4NAPgAirJvp1Q9w_JKdcZgY,6983
12
+ comicforge/_skill/SKILL.md,sha256=StwT5_nJUbpOUCAlHz886SDbO_p2NMDxVAhdwWx3ADg,13772
13
+ comicforge/_skill/reference.md,sha256=FmjJlAKHtOfzmR3Rb2PKeoMxEWR5BZNTze_S2KECj9M,22081
14
+ comicforge-0.0.1.dist-info/METADATA,sha256=yn_ZwEya1emxHogLK8wjOwMvNHYsAvP-_olDuvH3TNY,5652
15
+ comicforge-0.0.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
16
+ comicforge-0.0.1.dist-info/entry_points.txt,sha256=-79dTD3fWo81P5RApa0IJ_N8HIYCXnacavJtOY_Tg6k,77
17
+ comicforge-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ cmf = comicforge.cli:main
3
+ comicforge = comicforge.cli:main