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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +106 -2
- data/lib/zer0_image_generator/abc/style_pack.rb +145 -0
- data/lib/zer0_image_generator/abc.rb +75 -0
- data/lib/zer0_image_generator/all.rb +57 -0
- data/lib/zer0_image_generator/claude/client.rb +291 -0
- data/lib/zer0_image_generator/claude/orchestration.rb +128 -0
- data/lib/zer0_image_generator/cli.rb +318 -0
- data/lib/zer0_image_generator/config.rb +192 -0
- data/lib/zer0_image_generator/constants.rb +177 -0
- data/lib/zer0_image_generator/content.rb +379 -0
- data/lib/zer0_image_generator/engine.rb +21 -0
- data/lib/zer0_image_generator/freesvg/cache.rb +167 -0
- data/lib/zer0_image_generator/freesvg/client.rb +467 -0
- data/lib/zer0_image_generator/http.rb +264 -0
- data/lib/zer0_image_generator/library.rb +201 -0
- data/lib/zer0_image_generator/logging.rb +236 -0
- data/lib/zer0_image_generator/preview_generator.py +1072 -100
- data/lib/zer0_image_generator/prompt.rb +48 -0
- data/lib/zer0_image_generator/providers/base.rb +152 -0
- data/lib/zer0_image_generator/providers/gemini.rb +60 -0
- data/lib/zer0_image_generator/providers/local.rb +90 -0
- data/lib/zer0_image_generator/providers/openai.rb +102 -0
- data/lib/zer0_image_generator/providers/stability.rb +64 -0
- data/lib/zer0_image_generator/providers/xai.rb +81 -0
- data/lib/zer0_image_generator/providers/xai_auth.rb +270 -0
- data/lib/zer0_image_generator/providers.rb +22 -0
- data/lib/zer0_image_generator/runner.rb +573 -0
- data/lib/zer0_image_generator/settings.rb +386 -0
- data/lib/zer0_image_generator/stats.rb +61 -0
- data/lib/zer0_image_generator/support/py_random.rb +189 -0
- data/lib/zer0_image_generator/svg/banner_seed.rb +241 -0
- data/lib/zer0_image_generator/svg/generators/flowfield.rb +154 -0
- data/lib/zer0_image_generator/svg/generators/invaders.rb +125 -0
- data/lib/zer0_image_generator/svg/generators/lowpoly.rb +254 -0
- data/lib/zer0_image_generator/svg/generators/lsystem.rb +228 -0
- data/lib/zer0_image_generator/svg/generators/mandala.rb +155 -0
- data/lib/zer0_image_generator/svg/generators/pixelquest.rb +144 -0
- data/lib/zer0_image_generator/svg/generators/starmap.rb +179 -0
- data/lib/zer0_image_generator/svg/lint.rb +400 -0
- data/lib/zer0_image_generator/svg/local_renderer.rb +606 -0
- data/lib/zer0_image_generator/svg/pixel_kit.rb +167 -0
- data/lib/zer0_image_generator/svg/rasterizer.rb +196 -0
- data/lib/zer0_image_generator/svg/sanitizer.rb +159 -0
- data/lib/zer0_image_generator/version.rb +1 -1
- metadata +43 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 94ef9be8acf2f3c67c09f13a916886b5cb902677a949948833ee75bf11da456b
|
|
4
|
+
data.tar.gz: 67da956c8c22361911764a8103a62a3075c615ea8ecde4145e1cf61a39152489
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|