zer0-image-generator 0.6.0 → 0.8.0

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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/README.md +106 -2
  4. data/lib/zer0_image_generator/abc/style_pack.rb +145 -0
  5. data/lib/zer0_image_generator/abc.rb +75 -0
  6. data/lib/zer0_image_generator/all.rb +57 -0
  7. data/lib/zer0_image_generator/claude/client.rb +291 -0
  8. data/lib/zer0_image_generator/claude/orchestration.rb +128 -0
  9. data/lib/zer0_image_generator/cli.rb +318 -0
  10. data/lib/zer0_image_generator/config.rb +192 -0
  11. data/lib/zer0_image_generator/constants.rb +177 -0
  12. data/lib/zer0_image_generator/content.rb +379 -0
  13. data/lib/zer0_image_generator/engine.rb +21 -0
  14. data/lib/zer0_image_generator/freesvg/cache.rb +167 -0
  15. data/lib/zer0_image_generator/freesvg/client.rb +467 -0
  16. data/lib/zer0_image_generator/http.rb +264 -0
  17. data/lib/zer0_image_generator/library.rb +201 -0
  18. data/lib/zer0_image_generator/logging.rb +236 -0
  19. data/lib/zer0_image_generator/preview_generator.py +1072 -100
  20. data/lib/zer0_image_generator/prompt.rb +48 -0
  21. data/lib/zer0_image_generator/providers/base.rb +152 -0
  22. data/lib/zer0_image_generator/providers/gemini.rb +60 -0
  23. data/lib/zer0_image_generator/providers/local.rb +90 -0
  24. data/lib/zer0_image_generator/providers/openai.rb +102 -0
  25. data/lib/zer0_image_generator/providers/stability.rb +64 -0
  26. data/lib/zer0_image_generator/providers/xai.rb +81 -0
  27. data/lib/zer0_image_generator/providers/xai_auth.rb +270 -0
  28. data/lib/zer0_image_generator/providers.rb +22 -0
  29. data/lib/zer0_image_generator/runner.rb +573 -0
  30. data/lib/zer0_image_generator/settings.rb +386 -0
  31. data/lib/zer0_image_generator/stats.rb +61 -0
  32. data/lib/zer0_image_generator/support/py_random.rb +189 -0
  33. data/lib/zer0_image_generator/svg/banner_seed.rb +241 -0
  34. data/lib/zer0_image_generator/svg/generators/flowfield.rb +154 -0
  35. data/lib/zer0_image_generator/svg/generators/invaders.rb +125 -0
  36. data/lib/zer0_image_generator/svg/generators/lowpoly.rb +254 -0
  37. data/lib/zer0_image_generator/svg/generators/lsystem.rb +228 -0
  38. data/lib/zer0_image_generator/svg/generators/mandala.rb +155 -0
  39. data/lib/zer0_image_generator/svg/generators/pixelquest.rb +144 -0
  40. data/lib/zer0_image_generator/svg/generators/starmap.rb +179 -0
  41. data/lib/zer0_image_generator/svg/lint.rb +400 -0
  42. data/lib/zer0_image_generator/svg/local_renderer.rb +606 -0
  43. data/lib/zer0_image_generator/svg/pixel_kit.rb +167 -0
  44. data/lib/zer0_image_generator/svg/rasterizer.rb +196 -0
  45. data/lib/zer0_image_generator/svg/sanitizer.rb +159 -0
  46. data/lib/zer0_image_generator/version.rb +1 -1
  47. metadata +43 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 15dc8c56843fbbebfe9e26afc0f48cb54cfc75cc8a9b938db40a0cd9f6cd389d
4
- data.tar.gz: b7ab70a697bbc9161ec8ccc16dfced4fb4e560763ba020803c682e25a0803d0b
3
+ metadata.gz: 94ef9be8acf2f3c67c09f13a916886b5cb902677a949948833ee75bf11da456b
4
+ data.tar.gz: 67da956c8c22361911764a8103a62a3075c615ea8ecde4145e1cf61a39152489
5
5
  SHA512:
6
- metadata.gz: 187f71882b0092c5d99c1df0b1685d941770bbc1dbaf62dfa330a9ec17433248423669327f6188a957b1a9ec31ece5b12b1e00f04c274828c17572d9a2a94952
7
- data.tar.gz: 2db8e40e2698daba7d05ad47f3c981f608ad118841f3efcb21661616295d9b62f1d1a1788a2509ae7c45e1f538685f1baa6f7505a1b7702bc701fea9ad9ffcc3
6
+ metadata.gz: 81cdf3446d4352ec701b51a85e7051df57ae891a6cd2f22803cb5a383350076b7d4afc52a676c181bc5eb5832cbe99b0fd557c84732b03de945050a888d92403
7
+ data.tar.gz: a6687659e49d06b6b32cfb6c1e077ff625e014f88cef5e3be3d20c131720d59808370fc436d9f1d74d548b9679c05bf0fa4ffe3c3faaed22c49ca057be081460
data/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.8.0](https://github.com/bamr87/zer0-image-generator/compare/zer0-image-generator/v0.7.0...zer0-image-generator/v0.8.0) (2026-10-03)
9
+
10
+
11
+ ### Features
12
+
13
+ * **config:** make `rasterizer` a site-level knob (SVG-only mode) ([9f53375](https://github.com/bamr87/zer0-image-generator/commit/9f53375dbefb2a3d1dfc5eddab0801378b2949e6))
14
+ * **local:** content-aware SVG banners + site-level `rasterizer` knob ([2bddc3f](https://github.com/bamr87/zer0-image-generator/commit/2bddc3f57dbd3926a180d5eb241087f202fd0fcb))
15
+ * **local:** content-aware SVG banners instead of one recoloured skyline ([dc0b4bb](https://github.com/bamr87/zer0-image-generator/commit/dc0b4bb2a9b177614d1aa0c765726b8fcc779bf2))
16
+ * **providers:** capability ladder — auto, claude SVG, shared collection default ([55da59f](https://github.com/bamr87/zer0-image-generator/commit/55da59f48b03eb205209cd70d88751a1f75bfab1))
17
+ * **providers:** capability ladder — auto, Claude-authored SVG, shared collection default ([0088a94](https://github.com/bamr87/zer0-image-generator/commit/0088a949d3308c956a3a035dd03a12b33c424bd4))
18
+ * **providers:** Grok OAuth for the xAI renderer, API key as the fallback ([#19](https://github.com/bamr87/zer0-image-generator/issues/19)) ([c57e527](https://github.com/bamr87/zer0-image-generator/commit/c57e52799aa744110f1ce093a0bfb542ff971b0e))
19
+
20
+ ## [0.7.0](https://github.com/bamr87/zer0-image-generator/compare/zer0-image-generator/v0.6.0...zer0-image-generator/v0.7.0) (2026-07-24)
21
+
22
+
23
+ ### Features
24
+
25
+ * **abc:** ABC alphabet-book illustration plugin ([b817ffd](https://github.com/bamr87/zer0-image-generator/commit/b817ffd83ef3728933cd4bd70b8a66cc16543599))
26
+ * Dockerized Rails control panel + pure-Ruby engine port ([#8](https://github.com/bamr87/zer0-image-generator/issues/8)) ([a03ff9d](https://github.com/bamr87/zer0-image-generator/commit/a03ff9d0e37aee29c32e5e696d8cf04bfdeb2075))
27
+
8
28
  ## [0.6.0](https://github.com/bamr87/zer0-image-generator/compare/zer0-image-generator/v0.5.0...zer0-image-generator/v0.6.0) (2026-07-23)
9
29
 
10
30
 
data/README.md CHANGED
@@ -6,6 +6,19 @@ AI preview/social images for **any Jekyll site** — Claude directs and reviews,
6
6
 
7
7
  Extracted from the [zer0-mistakes](https://github.com/bamr87/zer0-mistakes) theme's consolidated engine and generalized: every theme-specific assumption is now a config knob with zer0-compatible defaults, so the theme and this plugin stay **separate but portable**.
8
8
 
9
+ Three ways to drive it: the `jekyll preview-images` **Gem/CLI**, the standalone **Python engine**, and a **web app** ([web/](web/README.md)) — a Dockerized Rails control panel that runs a pure-Ruby port of the engine ([lib/zer0_image_generator/](lib/zer0_image_generator/), pinned byte-for-byte against the Python original), so all three share one behavior. In the browser you can:
10
+
11
+ - register sites, edit their `preview_images:` config, and run generation with a
12
+ live-streaming log;
13
+ - **forge a standalone image** — Claude directs the art (via a Claude Code OAuth
14
+ token), a renderer produces it, Claude reviews it, and you can process any version further with OpenAI;
15
+ - drive the `tools/svg/` generators from knob forms and browse the example
16
+ library.
17
+
18
+ ```bash
19
+ SITES_DIR=/path/to/your/jekyll/sites docker compose up --build # → localhost:3000
20
+ ```
21
+
9
22
  ## How it works
10
23
 
11
24
  Each post/page without a preview goes through a three-stage pipeline:
@@ -19,10 +32,13 @@ Each post/page without a preview goes through a three-stage pipeline:
19
32
  | provider | renderer | credential |
20
33
  | ----------- | ------------------------------------------ | ------------------- |
21
34
  | **openai** (default) | gpt-image-2 / dall-e-3 (+ `--enhance`) | `OPENAI_API_KEY` |
22
- | xai | grok-2-image | `XAI_API_KEY` |
35
+ | xai | grok-2-image | Grok OAuth token or `XAI_API_KEY` |
23
36
  | stability | Stable Diffusion XL | `STABILITY_API_KEY` |
24
37
  | gemini | gemini-2.5-flash-image | `GEMINI_API_KEY` |
25
- | local | deterministic template SVG → PNG | none (CI-safe) |
38
+ | local | content-aware deterministic SVG per page | none (CI-safe) |
39
+ | **claude** | Claude AUTHORS the banner as SVG | Claude credential |
40
+ | **default** | one shared SVG per collection/section | none (the floor) |
41
+ | **auto** | picks the best rung below | whatever is wired |
26
42
 
27
43
  Claude never renders pixels (the Anthropic API has no image endpoint); without a Claude credential the analyze/review stages degrade gracefully to a template prompt and the renderer still runs.
28
44
 
@@ -80,6 +96,9 @@ preview_images:
80
96
  claude_effort: low # output_config.effort for the brief/review calls
81
97
  # (low = fastest, right-sized for short briefs;
82
98
  # high = deepest; '' sends no effort parameter)
99
+ rasterizer: auto # SVG→PNG tool for the SVG providers (local,
100
+ # claude): auto | rsvg | inkscape | magick |
101
+ # playwright | none. `none` = SVG-only (below).
83
102
  # --- portability knobs (the zer0-isms, now configurable) ---
84
103
  collections: [posts] # list of collections, or 'auto' to discover
85
104
  # them from Jekyll's own `collections:` map
@@ -189,6 +208,31 @@ export ANTHROPIC_API_KEY="..." # 3. console.anthropic.com
189
208
 
190
209
  Keys are read from the environment or a git-ignored `.env` at the site root. On a Claude Pro/Max subscription the orchestration costs nothing extra; only the renderer bills per image.
191
210
 
211
+ ### Grok OAuth (xAI renderer)
212
+
213
+ `--provider xai` accepts a **Grok OAuth access token** as well as an API key, and tries them in that order — so a SuperGrok / X Premium+ login can render banners without a console.x.ai key:
214
+
215
+ ```bash
216
+ export XAI_OAUTH_TOKEN="..." # 1. an OAuth access token (GROK_OAUTH_TOKEN also works)
217
+ # …or a credentials file from a Grok login — ~/.grok/auth.json by default,
218
+ # or set XAI_OAUTH_CREDENTIALS / GROK_OAUTH_CREDENTIALS to point elsewhere
219
+ export XAI_API_KEY="..." # 2. console.x.ai — and the automatic fallback
220
+ ```
221
+
222
+ Both are `Authorization: Bearer` credentials on the same endpoint, which is what makes the fallback real rather than cosmetic: **if the OAuth token is expired or the API rejects it (401/403) and an `XAI_API_KEY` is present, the same request is retried with the key** and the page still renders. A token whose recorded expiry has already passed is skipped without spending a request at all. Other failures (a 500, a bad prompt) are *not* credential problems, so they never burn the fallback.
223
+
224
+ The engine only ever *reads* a token — it does not mint or refresh one, and never writes to the credentials file, because xAI publishes neither a device-code endpoint nor a public client id. Re-run your Grok login when a token expires.
225
+
226
+ | variable | effect |
227
+ | --- | --- |
228
+ | `XAI_OAUTH_TOKEN` / `GROK_OAUTH_TOKEN` | OAuth access token, tried first |
229
+ | `XAI_OAUTH_CREDENTIALS` / `GROK_OAUTH_CREDENTIALS` | explicit path to a Grok credentials JSON (wins outright over `~/.grok/auth.json`) |
230
+ | `XAI_API_KEY` | API key — the fallback rung |
231
+ | `XAI_AUTH` | `auto` (default), `oauth` or `api_key` — pin one rung |
232
+ | `XAI_BASE_URL` | override `https://api.x.ai/v1` |
233
+
234
+ The run's config banner names the credential it picked (`xai auth: XAI_OAUTH_TOKEN (Grok OAuth token), falling back to XAI_API_KEY (xAI API key)`), and a fallback mid-run is logged as a warning — a silent swap would hide a token that has quietly stopped working.
235
+
192
236
  ## CLI reference
193
237
 
194
238
  `jekyll preview-images` exposes the engine's flags (Jekyll claims `-s/-d/-p` globally, so use long forms there; the Python CLI keeps all short flags):
@@ -204,8 +248,54 @@ Keys are read from the environment or a git-ignored `.env` at the site root. On
204
248
  --assets-prefix PREFIX --no-auto-prefix --batch N --log-file FILE
205
249
  ```
206
250
 
251
+ ### `provider: auto` — the capability ladder
252
+
253
+ Set `provider: auto` and the engine uses the best renderer the environment can actually reach:
254
+
255
+ 1. **A raster image API** (`OPENAI_API_KEY` / `XAI_API_KEY` or a Grok OAuth token / `STABILITY_API_KEY` / `GEMINI_API_KEY`). If a Claude credential is *also* present, `prompt_engine: claude` has Claude write the art brief that renderer works from, and vision-review the result — the full three-stage pipeline.
256
+ 2. **A Claude credential alone** → the `claude` provider, where Claude *authors* the banner as SVG. Claude cannot render pixels, but vector markup is the one image format a language model can emit directly, so this is genuine per-article art with no image-model spend. Output goes through the same sanitizer as every other SVG, so a model that emits a `<script>`, a remote `href`, or a raster `data:` payload gets it stripped rather than committed.
257
+ 3. **Nothing wired** → the `default` provider: **one shared banner per collection/section**.
258
+
259
+ Why a shared default rather than per-page procedural art: with no credential anywhere, generating a distinct composition per page produces N near-identical images under N names — 91 flow fields differing only by palette read as one picture repeated, and cost 91 files to say so. The floor is honest about that instead. Pages never touched by an image model share one deliberate banner for their section, and the repo carries one file per section rather than one per page.
260
+
261
+ Auto requires a real Claude *credential* (`CLAUDE_CODE_OAUTH_TOKEN` / `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_API_KEY`) to leave rung 3 — deliberately not the same test as `AnthropicClient.available()`, which also accepts a `claude` binary on `PATH`. An installed CLI is not proof of a working login, and promoting an unauthenticated machine off the default rung would fail every page at generation time instead of quietly shipping the shared banner. An explicit `--provider claude` still rides the CLI, because that is a human asserting it works.
262
+
263
+ An explicitly configured provider is always honored as written; only `auto` walks the ladder.
264
+
265
+ ### Content-aware local banners
266
+
267
+ The `local` renderer does not draw one template in six colours. It reads the page's own context — its **section**, **tags**, **title**, **description** and **body** — and picks both the composition and the palette from it, so a shell hack and a narrative essay do not come out looking like siblings.
268
+
269
+ | composition | reads as | selected by |
270
+ | --- | --- | --- |
271
+ | `circuit` | orthogonal traces, pads, vias, chips | `hacks`, shell/docker/git/ci vocabulary |
272
+ | `pixelscene` | pixel-art side-scroller | `tools`, reviews, editors, games |
273
+ | `flowfield` | streamlines through a noise field | `field-notes`, essays, AI/automation |
274
+ | `lowpoly` | faceted gradient field | `docs`, guides, architecture, schemas |
275
+ | `starfield` | constellations over layered ridges | security, secrets, audits, exploration |
276
+
277
+ Fields are **weighted, not pooled**: a section name is a deliberate editorial choice and tags and titles are hand-written, so they carry most of the signal, while the long incidental body is capped. Matching is on whole words — a bare substring test fires `ci` inside "de**ci**sion" and drags an entire dev blog toward one look. With no keyword hit anywhere the seed picks, so every page still gets art.
278
+
279
+ Determinism is unchanged and remains a hard contract: the same article renders the same bytes on both engines. The seed comes from the article's **content** rather than its filename, so retitling or re-tagging a piece re-rolls its art — and two posts whose filenames merely sort next to each other no longer come out kin.
280
+
281
+ Every composition is authored at the banner frame (1536×1024), so the sanitizer's forced `viewBox` is a no-op rather than a squash, and each emits `role="img"` plus a `<title>`.
282
+
207
283
  The `local` provider rasterizes its SVG via the first available of `rsvg-convert` (`brew install librsvg` / `apt install librsvg2-bin`), `inkscape`, ImageMagick, or a vendored Playwright helper.
208
284
 
285
+ ### SVG-only sites
286
+
287
+ `rasterizer: none` makes the vector banner the deliverable: the SVG providers (`local`, `claude`) write the sanitized `.svg`, skip rasterization entirely, and stamp `preview: /assets/images/previews/<slug>.svg` into front matter. No PNG is produced and none is expected — the engine says "SVG-only mode" instead of nagging about a missing librsvg.
288
+
289
+ ```yaml
290
+ preview_images:
291
+ provider: local # the deterministic, key-free SVG renderer
292
+ rasterizer: none # ... and keep its output as SVG
293
+ ```
294
+
295
+ It is a site-level policy, so it belongs in `_config.yml` — every entry point (the `jekyll preview-images` CLI, a bare `python3 preview_generator.py`, the web app, CI) picks it up with no per-invocation flag. `--rasterizer` and `IMAGE_RASTERIZER` still override it for a one-off run.
296
+
297
+ Banners land at roughly 10–15 KB each instead of 1–2 MB, they diff as text in review, and they scale to any viewport. The trade-off: some social-card scrapers still prefer a raster `og:image`, so keep `rasterizer: auto` if link-preview cards matter more than repository weight.
298
+
209
299
  ## CI usage
210
300
 
211
301
  ```yaml
@@ -218,6 +308,20 @@ The `local` provider rasterizes its SVG via the first available of `rsvg-convert
218
308
 
219
309
  Generation is script-driven on demand — it is never part of `jekyll build` (builds stay fast, deterministic, and secret-free; GitHub Pages safe mode is irrelevant to it).
220
310
 
311
+ ## ABC alphabet-book plugin
312
+
313
+ Beyond one-banner-per-page previews, this gem is the **illustration plugin** for the fleet's children's-book pipeline: it owns the art-style catalog and composes one text-free plate per alphabet letter of a toddler ABC book. The [zer0-CMS](https://github.com/bamr87/zer0-CMS) Rails wizard picks an `art_style` and emits an *ABC Book Spec*; [drsai](https://github.com/bamr87/drsai) ships it into the `books` collection and renders each letter's prompt.
314
+
315
+ ```ruby
316
+ require "zer0_image_generator/abc"
317
+ Zer0ImageGenerator::Abc::StylePack.default.render_prompt(
318
+ letter: "A", word: "Automation",
319
+ subject: "a friendly robot arm stacking soft blocks",
320
+ style: "isometric-tech-toy")
321
+ ```
322
+
323
+ The style catalog (`lib/zer0_image_generator/abc/art_styles.yml`) is the source of truth shared, by vendored copy, with zer0-CMS and the theme. See [docs/abc-plugin.md](docs/abc-plugin.md).
324
+
221
325
  ## Relationship to zer0-mistakes
222
326
 
223
327
  The [zer0-mistakes](https://github.com/bamr87/zer0-mistakes) theme renders `preview:` values with a pure-Liquid include (GitHub-Pages-safe, no plugin needed) and currently vendors this engine at `scripts/lib/preview_generator.py`. This repo is the portable home: the theme is expected to consume the gem (or curl the engine from here) in a follow-up, so there is exactly ONE engine.
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Zer0ImageGenerator
6
+ # ABC / alphabet-book illustration support.
7
+ #
8
+ # This module is the plugin seam by which the fleet's content engine
9
+ # (zer0-CMS) and publisher (drsai) drive THIS gem to illustrate children's
10
+ # ABC books. It is deliberately self-contained and ADDITIVE: it requires
11
+ # nothing from the byte-identity engine port (constants.rb / prompt.rb /
12
+ # runner.rb) and changes none of it, so the deterministic `local` provider
13
+ # and every committed sample stay bit-for-bit unchanged.
14
+ #
15
+ # It owns two things:
16
+ # * the art-style catalog (art_styles.yml) — the SOURCE OF TRUTH shared,
17
+ # by vendored copy, with zer0-CMS and the zer0-mistakes theme; and
18
+ # * StylePack, which composes one raster prompt per alphabet letter from a
19
+ # style id + a few knobs, in a way that is stable and reproducible.
20
+ #
21
+ # The composed prompt is text-free by contract (the big letter is HTML
22
+ # typography in the theme), so raster models never garble spelling.
23
+ module Abc
24
+ CATALOG_PATH = File.join(__dir__, "art_styles.yml")
25
+
26
+ class UnknownStyle < StandardError; end
27
+
28
+ # Loads and indexes art_styles.yml. Frozen after load; cheap to keep one
29
+ # shared instance via .default, or build your own for a custom catalog.
30
+ class StylePack
31
+ attr_reader :version, :styles, :palettes, :backgrounds, :lettering,
32
+ :moods, :safety_suffix
33
+
34
+ def self.default
35
+ @default ||= from_file(CATALOG_PATH)
36
+ end
37
+
38
+ def self.from_file(path)
39
+ new(YAML.safe_load_file(path))
40
+ end
41
+
42
+ def initialize(catalog)
43
+ @version = catalog.fetch("version", 1)
44
+ @styles = index(catalog.fetch("styles"))
45
+ options = catalog.fetch("options", {})
46
+ @palettes = index(options.fetch("palettes", []))
47
+ @backgrounds = index(options.fetch("backgrounds", []))
48
+ @lettering = index(options.fetch("lettering", []))
49
+ @moods = index(options.fetch("moods", []))
50
+ @safety_suffix = squish(catalog.fetch("safety_suffix", ""))
51
+ freeze
52
+ end
53
+
54
+ # All selectable style ids, sorted — handy for a wizard's menu.
55
+ def style_ids
56
+ @styles.keys.sort
57
+ end
58
+
59
+ def style(id)
60
+ @styles[id.to_s] || raise(UnknownStyle, "unknown ABC art style: #{id.inspect} " \
61
+ "(known: #{style_ids.join(', ')})")
62
+ end
63
+
64
+ def style?(id)
65
+ @styles.key?(id.to_s)
66
+ end
67
+
68
+ # Compose the raster prompt for a single letter.
69
+ #
70
+ # letter — "A".."Z" (rendered by the theme, never drawn into the art)
71
+ # word — the concept the letter stands for, e.g. "Automation"
72
+ # subject — a concrete, drawable scene for the word, e.g.
73
+ # "a friendly robot arm stacking blocks"
74
+ # style — a style id from the catalog (String/Symbol)
75
+ # palette:/background:/mood: — option ids; nil falls back to the
76
+ # style's defaults (palette) or a sensible default.
77
+ #
78
+ # Returns a single-line String prompt. Deterministic: same inputs → same
79
+ # bytes, so committed books re-render identically.
80
+ def render_prompt(letter:, word:, subject:, style:, palette: nil,
81
+ background: "plain", mood: "playful")
82
+ sty = style(style)
83
+ palette_id = palette || sty["default_palette"]
84
+
85
+ parts = [
86
+ squish(sty.fetch("prompt")),
87
+ fragment(@backgrounds, background),
88
+ fragment(@palettes, palette_id),
89
+ "Subject: #{subject.to_s.strip}, standing for the word " \
90
+ "#{word.to_s.strip.upcase} (letter #{letter.to_s.strip.upcase}).",
91
+ fragment(@moods, mood),
92
+ @safety_suffix
93
+ ].compact.reject(&:empty?)
94
+
95
+ squish(parts.join(". ").gsub(/\.\s*\./, ".").strip)
96
+ end
97
+
98
+ # The default HTML lettering hint for a style (theme consumes this).
99
+ def lettering_for(style_id)
100
+ style(style_id)["default_lettering"]
101
+ end
102
+
103
+ # A compact, serialisable description of the whole catalog — what a
104
+ # wizard renders as its style/option menus.
105
+ def to_menu
106
+ {
107
+ "version" => @version,
108
+ "styles" => @styles.values.map do |s|
109
+ s.slice("id", "name", "summary", "default_palette",
110
+ "default_lettering", "best_for")
111
+ end,
112
+ "palettes" => menu_of(@palettes),
113
+ "backgrounds" => menu_of(@backgrounds),
114
+ "lettering" => menu_of(@lettering),
115
+ "moods" => menu_of(@moods)
116
+ }
117
+ end
118
+
119
+ private
120
+
121
+ def index(list)
122
+ list.each_with_object({}) { |item, acc| acc[item.fetch("id")] = item }.freeze
123
+ end
124
+
125
+ def menu_of(hash)
126
+ hash.values.map { |v| v.slice("id", "name") }
127
+ end
128
+
129
+ def fragment(catalog, id)
130
+ return nil if id.nil?
131
+
132
+ entry = catalog[id.to_s]
133
+ return nil if entry.nil?
134
+
135
+ frag = entry["fragment"]
136
+ frag.nil? ? nil : squish(frag)
137
+ end
138
+
139
+ # Collapse folded-YAML newlines / runs of whitespace into single spaces.
140
+ def squish(text)
141
+ text.to_s.gsub(/\s+/, " ").strip
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "abc/style_pack"
4
+
5
+ module Zer0ImageGenerator
6
+ module Abc
7
+ # High-level plugin entry point: turn an ABC Book Spec (the JSON/YAML the
8
+ # zer0-CMS engine emits and drsai ships) into concrete render work, using
9
+ # the shared art-style catalog for prompt composition.
10
+ #
11
+ # This is what makes zer0-image-generator "a plugin" of the pipeline: the
12
+ # content engine owns WHAT each letter means; this gem owns HOW it looks and
13
+ # how it renders. Both sides agree on the StylePack catalog and the spec
14
+ # shape below.
15
+ module Plugin
16
+ module_function
17
+
18
+ # A single unit of render work — mirrors drsai's tools Job so either the
19
+ # Python illustrator or this gem's Runner can consume a plan.
20
+ RenderJob = Struct.new(:letter, :word, :prompt, :out_path, :size, keyword_init: true)
21
+
22
+ # Landscape by default reads well for a big centred subject on a card.
23
+ DEFAULT_SIZE = "1024x1024"
24
+
25
+ # Compose a prompt for every letter in `book`, mutating a copy of its
26
+ # `alphabet` entries to carry a `prompt` (only where one is absent so
27
+ # hand-authored overrides win). Returns the updated book hash.
28
+ #
29
+ # `book` is an ABC Book Spec hash with at least:
30
+ # "art_style" => catalog style id
31
+ # "palette"/"background"/"mood" => option ids (optional)
32
+ # "alphabet" => [ { "letter", "word", "subject", "prompt"? }, ... ]
33
+ def compose_prompts(book, pack: StylePack.default)
34
+ style = book.fetch("art_style")
35
+ palette = book["palette"]
36
+ background = book["background"] || "plain"
37
+ mood = book["mood"] || "playful"
38
+
39
+ alphabet = Array(book["alphabet"]).map do |entry|
40
+ e = entry.dup
41
+ if e["prompt"].nil? || e["prompt"].to_s.strip.empty?
42
+ e["prompt"] = pack.render_prompt(
43
+ letter: e.fetch("letter"), word: e.fetch("word"),
44
+ subject: e.fetch("subject"), style: style,
45
+ palette: palette, background: background, mood: mood
46
+ )
47
+ end
48
+ e
49
+ end
50
+
51
+ book.merge("alphabet" => alphabet)
52
+ end
53
+
54
+ # Build the list of RenderJobs for the letters that still need art.
55
+ # `root` resolves each entry's leading-slash `image` path; a job is only
56
+ # emitted when the target file does not already exist (idempotent, like
57
+ # the drsai illustrator).
58
+ def build_plan(book, root:, size: DEFAULT_SIZE, pack: StylePack.default)
59
+ composed = compose_prompts(book, pack: pack)
60
+ Array(composed["alphabet"]).filter_map do |entry|
61
+ image = entry["image"]
62
+ next if image.nil? || image.to_s.empty?
63
+
64
+ out = File.join(root.to_s, image.to_s.sub(%r{\A/}, ""))
65
+ next if File.exist?(out)
66
+
67
+ RenderJob.new(
68
+ letter: entry["letter"], word: entry["word"],
69
+ prompt: entry["prompt"], out_path: out, size: size
70
+ )
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loads the entire engine in dependency order.
4
+ #
5
+ # The individual files require only their immediate collaborators, so that a
6
+ # single slice (a generator, the sanitizer) can be pulled in on its own. This
7
+ # file is the "give me everything" entry point the CLI and the Rails app use,
8
+ # where the whole pipeline — config, settings, providers, Claude, the runner —
9
+ # has to be present at once.
10
+ #
11
+ # Stdlib only, by policy.
12
+
13
+ require_relative "engine" # version + PyRandom + Error
14
+ require_relative "logging"
15
+ require_relative "stats"
16
+ require_relative "constants"
17
+ require_relative "config"
18
+ require_relative "settings"
19
+ require_relative "content"
20
+ require_relative "prompt"
21
+ require_relative "http"
22
+ require_relative "claude/client"
23
+ require_relative "claude/orchestration"
24
+ require_relative "svg/sanitizer"
25
+ require_relative "svg/local_renderer"
26
+ require_relative "svg/rasterizer"
27
+ require_relative "svg/lint"
28
+ require_relative "svg/pixel_kit"
29
+ require_relative "providers"
30
+ require_relative "runner"
31
+ require_relative "cli"
32
+
33
+ # ABC / alphabet-book plugin — additive and self-contained (touches none of the
34
+ # byte-identity engine above). Loaded here so the Rails web app and CLI can
35
+ # compose per-letter prompts for the fleet's children's-book pipeline.
36
+ require_relative "abc"
37
+
38
+ # The parametric SVG generators are optional for a preview run but always wanted
39
+ # by the studio, so load them here too.
40
+ Dir[File.join(__dir__, "svg", "generators", "*.rb")].sort.each { |file| require file }
41
+ require_relative "svg/banner_seed"
42
+
43
+ # The example library and FreeSVG sourcing are auxiliary features (library
44
+ # browsing, external sourcing) with no role in the generation pipeline, so they
45
+ # are required on demand by the code that uses them (see
46
+ # Zer0ImageGenerator.load_sourcing) rather than pulled into every load.
47
+
48
+ module Zer0ImageGenerator
49
+ # Load the on-demand sourcing + library modules. Separate from the core so a
50
+ # preview run or a studio render never depends on the FreeSVG client being
51
+ # present; called by the library/freesvg web controllers before they use it.
52
+ def self.load_sourcing
53
+ require_relative "library"
54
+ require_relative "freesvg/cache"
55
+ require_relative "freesvg/client"
56
+ end
57
+ end