zer0-image-generator 0.2.2 → 0.4.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 +26 -0
- data/README.md +42 -2
- data/lib/zer0_image_generator/preview_generator.py +108 -31
- data/lib/zer0_image_generator/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8abc47b88bb6730b91b0358f17328e477db2a16d56a315ac8a7189bc54e6471d
|
|
4
|
+
data.tar.gz: 68d3d6064d3fe7d709a1957baf245dbc12a7c02e6d9e81569f639ea140a5647a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 25b6769e4645e195e6a776790f3735cc608d5d76bc4057510de0a9e5cdeec8c31701840fbe8d893017fc159a932f29c3dca59031cf63a5e6729efcff63b58c54
|
|
7
|
+
data.tar.gz: 00aab1bfadf7cf22f32ca7c4abe3b4f02de118d025c8441e107928e1edd9b91ddfd848bd09218c8bfa445aaa5742f8d43577e6115f5b88346ba45dbb567e0148
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ 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.4.0] - 2026-07-15
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `claude_effort` knob (default `low`): the Claude art-brief and vision-review
|
|
13
|
+
calls now send `output_config: {effort: ...}` — short creative tasks don't
|
|
14
|
+
need deep reasoning, and `low` substantially cuts per-image orchestration
|
|
15
|
+
latency. Configurable per site (`preview_images.claude_effort`); `''`
|
|
16
|
+
disables. Gracefully retries without the parameter on models that reject it.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- README documents the batch-performance contract: one `--collection` run
|
|
21
|
+
parallelizes across files (`--parallel N`, default 4); per-file loops don't.
|
|
22
|
+
|
|
23
|
+
## [0.3.0] - 2026-07-14
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- Per-collection style/theme overrides: a `collection_styles:` map under
|
|
28
|
+
`preview_images:` lets each collection (`posts`, `docs`, `hacks`, …) carry
|
|
29
|
+
its own `style`, `style_modifiers`, `size`, `quality`, and `model` — so
|
|
30
|
+
field notes, docs, and hacks render with distinct visual identities. A
|
|
31
|
+
file's collection is derived from its `_<name>` directory; precedence is
|
|
32
|
+
author overrides → collection styles → CLI flags → env → config → defaults.
|
|
33
|
+
|
|
8
34
|
## [0.2.2] - 2026-07-14
|
|
9
35
|
|
|
10
36
|
### Fixed
|
data/README.md
CHANGED
|
@@ -87,18 +87,22 @@ preview_images:
|
|
|
87
87
|
output_dir: assets/images/previews
|
|
88
88
|
prompt_engine: claude # claude analyzes each article | template
|
|
89
89
|
review_engine: claude # claude vision-reviews each render | none
|
|
90
|
+
claude_effort: low # output_config.effort for the brief/review calls
|
|
91
|
+
# (low = fastest, right-sized for short briefs;
|
|
92
|
+
# high = deepest; '' sends no effort parameter)
|
|
90
93
|
# --- portability knobs (the zer0-isms, now configurable) ---
|
|
91
94
|
collections: [posts] # list of collections, or 'auto' to discover
|
|
92
95
|
# them from Jekyll's own `collections:` map
|
|
93
96
|
collections_dir: '' # falls back to Jekyll's top-level collections_dir
|
|
94
97
|
front_matter_key: preview # key to read/write (jekyll-seo-tag sites: image)
|
|
95
98
|
authors_file: _data/authors.yml # per-author style overrides ('' disables)
|
|
99
|
+
collection_styles: {} # per-collection style/theme overrides (below)
|
|
96
100
|
assets_prefix: /assets # written paths omit this prefix ...
|
|
97
101
|
auto_prefix: true # ... and it is re-added for existence checks
|
|
98
102
|
```
|
|
99
103
|
|
|
100
|
-
Priority per file: author overrides →
|
|
101
|
-
`_config.yml` → built-in defaults.
|
|
104
|
+
Priority per file: author overrides → collection styles → CLI flags →
|
|
105
|
+
environment variables → `_config.yml` → built-in defaults.
|
|
102
106
|
|
|
103
107
|
Jekyll's own top-level `source:` key is honored automatically: on a site with
|
|
104
108
|
`source: pages` (content, assets, and `_data` under `pages/`), every disk path
|
|
@@ -129,6 +133,42 @@ preview_images:
|
|
|
129
133
|
# front_matter_key/preview + /assets prefixing are already the defaults
|
|
130
134
|
```
|
|
131
135
|
|
|
136
|
+
### Batch performance
|
|
137
|
+
|
|
138
|
+
The engine parallelizes **within one invocation** (4 workers by default, `-j N`
|
|
139
|
+
to change). For many files, one collection run beats a loop of single-file
|
|
140
|
+
runs — the loop pays process startup per file and never engages the pool:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
bundle exec jekyll preview-images --collection posts --parallel 6 # ✓ parallel
|
|
144
|
+
for f in ...; do jekyll preview-images -f "$f"; done # ✗ serial
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Claude orchestration runs at `claude_effort: low` by default — right-sized for
|
|
148
|
+
a short art brief and a render verdict. Raise it (or set `claude_model`) if you
|
|
149
|
+
want deeper art direction at the cost of latency.
|
|
150
|
+
|
|
151
|
+
### Per-collection styles
|
|
152
|
+
|
|
153
|
+
Give each collection its own visual identity — tutorials render differently
|
|
154
|
+
from field notes, docs differently from reviews. Any of `style`,
|
|
155
|
+
`style_modifiers`, `size`, `quality`, and `model` can be set per collection;
|
|
156
|
+
everything else inherits the global config. A file's collection comes from its
|
|
157
|
+
`_<name>` directory; author overrides still win over collection styles.
|
|
158
|
+
|
|
159
|
+
```yaml
|
|
160
|
+
preview_images:
|
|
161
|
+
style: "retro pixel art" # global fallback
|
|
162
|
+
collection_styles:
|
|
163
|
+
posts:
|
|
164
|
+
style: "hand-inked editorial illustration, warm print tones"
|
|
165
|
+
docs:
|
|
166
|
+
style: "clean isometric technical diagram"
|
|
167
|
+
style_modifiers: "matte surfaces, labeled parts, blueprint accents"
|
|
168
|
+
hacks:
|
|
169
|
+
style: "circuit-board schematic, glowing traces"
|
|
170
|
+
```
|
|
171
|
+
|
|
132
172
|
### Per-author style overrides
|
|
133
173
|
|
|
134
174
|
If `authors_file` points at a YAML map, an author's `preview:` block overrides
|
|
@@ -17,6 +17,9 @@ knob with zer0-compatible defaults:
|
|
|
17
17
|
author overrides authors_file ("" disables) _data/authors.yml
|
|
18
18
|
collections collections (list, or "auto" to [posts]
|
|
19
19
|
discover from Jekyll's collections:)
|
|
20
|
+
collection styles collection_styles (map of {} (global style)
|
|
21
|
+
collection → style/style_modifiers/
|
|
22
|
+
size/quality/model overrides)
|
|
20
23
|
|
|
21
24
|
Architecture — Claude ORCHESTRATES, an image model RENDERS:
|
|
22
25
|
|
|
@@ -125,6 +128,8 @@ DEFAULTS: Dict[str, Any] = {
|
|
|
125
128
|
"prompt_engine": "claude", # claude analyzes the article; falls back to template
|
|
126
129
|
"review_engine": "claude", # claude vision-reviews the render; `none` disables
|
|
127
130
|
"claude_model": "", # empty → DEFAULT_CLAUDE_MODEL
|
|
131
|
+
"claude_effort": "low", # output_config.effort for brief/review calls
|
|
132
|
+
# (short creative tasks; "" sends no effort)
|
|
128
133
|
}
|
|
129
134
|
|
|
130
135
|
# Enhance mode (OpenAI /v1/images/edits — see OpenAIProvider.edit)
|
|
@@ -573,6 +578,22 @@ def read_authors(project_root: Path, authors_file: str) -> Dict[str, Any]:
|
|
|
573
578
|
return load_yaml_file(project_root / authors_file.strip().lstrip("/"))
|
|
574
579
|
|
|
575
580
|
|
|
581
|
+
# Settings fields an override block (author preview: / collection_styles:)
|
|
582
|
+
# may replace per file.
|
|
583
|
+
OVERRIDE_KEYS = ("style", "style_modifiers", "size", "quality", "model")
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _filter_override_block(block: Any) -> Dict[str, str]:
|
|
587
|
+
"""Keep only the recognized override keys with non-empty values."""
|
|
588
|
+
if not isinstance(block, dict):
|
|
589
|
+
return {}
|
|
590
|
+
return {
|
|
591
|
+
key: str(value).strip()
|
|
592
|
+
for key, value in block.items()
|
|
593
|
+
if key in OVERRIDE_KEYS and value is not None and str(value).strip()
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
|
|
576
597
|
def author_preview_overrides(authors: Dict[str, Any], author_key: Any) -> Dict[str, str]:
|
|
577
598
|
"""`preview:` override block for an author key (style/style_modifiers/size/
|
|
578
599
|
quality/model). `author:` may be a list or mapping in some posts — only a
|
|
@@ -582,15 +603,27 @@ def author_preview_overrides(authors: Dict[str, Any], author_key: Any) -> Dict[s
|
|
|
582
603
|
author = authors.get(author_key)
|
|
583
604
|
if not isinstance(author, dict):
|
|
584
605
|
return {}
|
|
585
|
-
|
|
586
|
-
|
|
606
|
+
return _filter_override_block(author.get("preview"))
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def collection_of(path: Path) -> str:
|
|
610
|
+
"""The file's collection, from the nearest `_<name>` ancestor directory
|
|
611
|
+
(Jekyll's collection layout) — '' for files outside any collection."""
|
|
612
|
+
for parent in Path(path).resolve().parents:
|
|
613
|
+
name = parent.name
|
|
614
|
+
if name.startswith("_") and len(name) > 1:
|
|
615
|
+
return name[1:]
|
|
616
|
+
return ""
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def collection_preview_overrides(
|
|
620
|
+
collection_styles: Dict[str, Any], collection: str
|
|
621
|
+
) -> Dict[str, str]:
|
|
622
|
+
"""`collection_styles:` override block for a collection — same keys as
|
|
623
|
+
author overrides, so each collection can carry its own visual theme."""
|
|
624
|
+
if not collection or not isinstance(collection_styles, dict):
|
|
587
625
|
return {}
|
|
588
|
-
return
|
|
589
|
-
key: str(value).strip()
|
|
590
|
-
for key, value in preview.items()
|
|
591
|
-
if key in ("style", "style_modifiers", "size", "quality", "model")
|
|
592
|
-
and value is not None and str(value).strip()
|
|
593
|
-
}
|
|
626
|
+
return _filter_override_block(collection_styles.get(collection))
|
|
594
627
|
|
|
595
628
|
|
|
596
629
|
def _env_flag(name: str) -> bool:
|
|
@@ -613,11 +646,13 @@ class Settings:
|
|
|
613
646
|
enabled: bool = True
|
|
614
647
|
collections: List[str] = field(default_factory=lambda: list(DEFAULTS["collections"]))
|
|
615
648
|
collections_dir: str = DEFAULTS["collections_dir"]
|
|
649
|
+
collection_styles: Dict[str, Any] = field(default_factory=dict)
|
|
616
650
|
front_matter_key: str = DEFAULTS["front_matter_key"]
|
|
617
651
|
authors_file: str = DEFAULTS["authors_file"]
|
|
618
652
|
prompt_engine: str = DEFAULTS["prompt_engine"]
|
|
619
653
|
review_engine: str = DEFAULTS["review_engine"]
|
|
620
654
|
claude_model: str = DEFAULTS["claude_model"]
|
|
655
|
+
claude_effort: str = DEFAULTS["claude_effort"]
|
|
621
656
|
|
|
622
657
|
dry_run: bool = False
|
|
623
658
|
verbose: bool = False
|
|
@@ -703,6 +738,10 @@ def resolve_settings(args: argparse.Namespace, config: Dict[str, Any]) -> Settin
|
|
|
703
738
|
),
|
|
704
739
|
enabled=bool(cfg("enabled", True)),
|
|
705
740
|
collections=[str(c) for c in collections],
|
|
741
|
+
collection_styles=(
|
|
742
|
+
cfg("collection_styles", {})
|
|
743
|
+
if isinstance(cfg("collection_styles", {}), dict) else {}
|
|
744
|
+
),
|
|
706
745
|
collections_dir=str(collections_dir or "").strip().strip("/"),
|
|
707
746
|
front_matter_key=(
|
|
708
747
|
args.front_matter_key
|
|
@@ -716,6 +755,7 @@ def resolve_settings(args: argparse.Namespace, config: Dict[str, Any]) -> Settin
|
|
|
716
755
|
prompt_engine=pick(args.prompt_engine, "PROMPT_ENGINE", "prompt_engine"),
|
|
717
756
|
review_engine=pick(args.review, "REVIEW_ENGINE", "review_engine"),
|
|
718
757
|
claude_model=str(cfg("claude_model", "") or ""),
|
|
758
|
+
claude_effort=str(cfg("claude_effort", DEFAULTS["claude_effort"]) or ""),
|
|
719
759
|
dry_run=args.dry_run or _env_flag("DRY_RUN"),
|
|
720
760
|
verbose=args.verbose or _env_flag("VERBOSE"),
|
|
721
761
|
force=args.force or _env_flag("FORCE"),
|
|
@@ -748,20 +788,38 @@ def resolve_settings(args: argparse.Namespace, config: Dict[str, Any]) -> Settin
|
|
|
748
788
|
return settings
|
|
749
789
|
|
|
750
790
|
|
|
751
|
-
def
|
|
752
|
-
|
|
753
|
-
|
|
791
|
+
def apply_preview_overrides(
|
|
792
|
+
settings: Settings, *override_blocks: Dict[str, str]
|
|
793
|
+
) -> Settings:
|
|
794
|
+
"""Per-file settings copy with override blocks applied in order — later
|
|
795
|
+
blocks win (pass collection styles first, author overrides on top)."""
|
|
796
|
+
merged: Dict[str, str] = {}
|
|
797
|
+
for block in override_blocks:
|
|
798
|
+
merged.update(block or {})
|
|
799
|
+
if not merged:
|
|
754
800
|
return settings
|
|
755
801
|
return replace(
|
|
756
802
|
settings,
|
|
757
|
-
|
|
758
|
-
style_modifiers=overrides.get("style_modifiers", settings.style_modifiers),
|
|
759
|
-
size=overrides.get("size", settings.size),
|
|
760
|
-
quality=overrides.get("quality", settings.quality),
|
|
761
|
-
model=overrides.get("model", settings.model),
|
|
803
|
+
**{key: merged.get(key, getattr(settings, key)) for key in OVERRIDE_KEYS},
|
|
762
804
|
)
|
|
763
805
|
|
|
764
806
|
|
|
807
|
+
def file_preview_settings(settings: Settings, cf: "ContentFile",
|
|
808
|
+
authors: Dict[str, Any]) -> Settings:
|
|
809
|
+
"""The per-file settings: global → collection_styles[collection] →
|
|
810
|
+
author preview: block (most specific wins)."""
|
|
811
|
+
coll_overrides = collection_preview_overrides(
|
|
812
|
+
settings.collection_styles, collection_of(cf.path))
|
|
813
|
+
author_overrides = author_preview_overrides(authors, cf.author)
|
|
814
|
+
result = apply_preview_overrides(settings, coll_overrides, author_overrides)
|
|
815
|
+
if coll_overrides:
|
|
816
|
+
info(f" ↳ Collection '{collection_of(cf.path)}' preview style applied")
|
|
817
|
+
if author_overrides:
|
|
818
|
+
info(f" ↳ Author '{cf.author}' preview overrides applied "
|
|
819
|
+
f"({settings.authors_file})")
|
|
820
|
+
return result
|
|
821
|
+
|
|
822
|
+
|
|
765
823
|
def model_family(model: str) -> Optional[str]:
|
|
766
824
|
m = (model or "").strip().lower()
|
|
767
825
|
for prefix, family in MODEL_FAMILIES.items():
|
|
@@ -1066,6 +1124,7 @@ def claude_article_brief(client: "AnthropicClient", cf: ContentFile,
|
|
|
1066
1124
|
text = client.complete(
|
|
1067
1125
|
ART_DIRECTOR_SYSTEM, article,
|
|
1068
1126
|
model=claude_model_for(settings), max_tokens=2048,
|
|
1127
|
+
effort=settings.claude_effort,
|
|
1069
1128
|
).strip()
|
|
1070
1129
|
if text:
|
|
1071
1130
|
debug(f"Claude art-direction brief: {text[:300]}...")
|
|
@@ -1105,6 +1164,7 @@ def claude_review_image(client: "AnthropicClient", image_path: Path,
|
|
|
1105
1164
|
text = client.complete_vision(
|
|
1106
1165
|
REVIEWER_SYSTEM, context, image_path,
|
|
1107
1166
|
model=claude_model_for(settings),
|
|
1167
|
+
effort=settings.claude_effort,
|
|
1108
1168
|
)
|
|
1109
1169
|
data = _extract_json_object(text)
|
|
1110
1170
|
if not data:
|
|
@@ -1442,6 +1502,7 @@ class AnthropicClient:
|
|
|
1442
1502
|
user_text: str,
|
|
1443
1503
|
model: str = DEFAULT_CLAUDE_MODEL,
|
|
1444
1504
|
max_tokens: int = CLAUDE_MAX_TOKENS,
|
|
1505
|
+
effort: str = "",
|
|
1445
1506
|
) -> str:
|
|
1446
1507
|
"""One Messages-API turn (or CLI run); returns concatenated text blocks.
|
|
1447
1508
|
|
|
@@ -1466,6 +1527,8 @@ class AnthropicClient:
|
|
|
1466
1527
|
"system": system_blocks,
|
|
1467
1528
|
"messages": [{"role": "user", "content": user_text}],
|
|
1468
1529
|
}
|
|
1530
|
+
if effort:
|
|
1531
|
+
payload["output_config"] = {"effort": effort}
|
|
1469
1532
|
|
|
1470
1533
|
def call(body: Dict[str, Any]) -> dict:
|
|
1471
1534
|
return with_retries(
|
|
@@ -1476,7 +1539,12 @@ class AnthropicClient:
|
|
|
1476
1539
|
try:
|
|
1477
1540
|
data = call(payload)
|
|
1478
1541
|
except HttpStatusError as exc:
|
|
1479
|
-
if exc.status == 400
|
|
1542
|
+
message = exc.message().lower() if exc.status == 400 else ""
|
|
1543
|
+
if exc.status == 400 and ("output_config" in message or "effort" in message):
|
|
1544
|
+
debug("Retrying without output_config parameter")
|
|
1545
|
+
payload.pop("output_config", None)
|
|
1546
|
+
data = call(payload)
|
|
1547
|
+
elif exc.status == 400 and "thinking" in message:
|
|
1480
1548
|
debug("Retrying without thinking parameter")
|
|
1481
1549
|
payload.pop("thinking", None)
|
|
1482
1550
|
data = call(payload)
|
|
@@ -1510,6 +1578,7 @@ class AnthropicClient:
|
|
|
1510
1578
|
image_path: Path,
|
|
1511
1579
|
model: str = DEFAULT_CLAUDE_MODEL,
|
|
1512
1580
|
max_tokens: int = 2048,
|
|
1581
|
+
effort: str = "",
|
|
1513
1582
|
) -> str:
|
|
1514
1583
|
"""One vision turn over a local PNG (review stage). CLI mode passes the
|
|
1515
1584
|
file path and lets `claude -p` read it; API modes embed base64."""
|
|
@@ -1541,10 +1610,25 @@ class AnthropicClient:
|
|
|
1541
1610
|
],
|
|
1542
1611
|
}],
|
|
1543
1612
|
}
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
)
|
|
1613
|
+
if effort:
|
|
1614
|
+
payload["output_config"] = {"effort": effort}
|
|
1615
|
+
|
|
1616
|
+
def call(body: Dict[str, Any]) -> dict:
|
|
1617
|
+
return with_retries(
|
|
1618
|
+
lambda: http_json(ANTHROPIC_API_URL, body, self.headers(), timeout=900),
|
|
1619
|
+
"Anthropic API (review)",
|
|
1620
|
+
)
|
|
1621
|
+
|
|
1622
|
+
try:
|
|
1623
|
+
data = call(payload)
|
|
1624
|
+
except HttpStatusError as exc:
|
|
1625
|
+
message = exc.message().lower() if exc.status == 400 else ""
|
|
1626
|
+
if exc.status == 400 and ("output_config" in message or "effort" in message):
|
|
1627
|
+
debug("Retrying review without output_config parameter")
|
|
1628
|
+
payload.pop("output_config", None)
|
|
1629
|
+
data = call(payload)
|
|
1630
|
+
else:
|
|
1631
|
+
raise
|
|
1548
1632
|
if data.get("stop_reason") == "refusal":
|
|
1549
1633
|
details = data.get("stop_details") or {}
|
|
1550
1634
|
raise ClaudeRefusal(details.get("category") if isinstance(details, dict) else None)
|
|
@@ -2025,13 +2109,8 @@ class Runner:
|
|
|
2025
2109
|
return
|
|
2026
2110
|
out_base = self.root / settings.output_dir / slug
|
|
2027
2111
|
|
|
2028
|
-
#
|
|
2029
|
-
file_settings =
|
|
2030
|
-
settings, author_preview_overrides(self.authors, cf.author)
|
|
2031
|
-
)
|
|
2032
|
-
if file_settings is not settings:
|
|
2033
|
-
info(f" ↳ Author '{cf.author}' preview overrides applied "
|
|
2034
|
-
f"({settings.authors_file})")
|
|
2112
|
+
# Style overrides apply only once an image/prompt is actually built.
|
|
2113
|
+
file_settings = file_preview_settings(settings, cf, self.authors)
|
|
2035
2114
|
|
|
2036
2115
|
# ---- Analyze: Claude reads the article and writes the art brief ----
|
|
2037
2116
|
base_prompt = build_prompt(cf, file_settings)
|
|
@@ -2114,9 +2193,7 @@ class Runner:
|
|
|
2114
2193
|
return
|
|
2115
2194
|
|
|
2116
2195
|
info(f"Enhancing preview for: {cf.title}")
|
|
2117
|
-
file_settings =
|
|
2118
|
-
settings, author_preview_overrides(self.authors, cf.author)
|
|
2119
|
-
)
|
|
2196
|
+
file_settings = file_preview_settings(settings, cf, self.authors)
|
|
2120
2197
|
prompt = build_enhance_prompt(cf, file_settings)
|
|
2121
2198
|
debug(f"Enhancement prompt: {prompt[:400]}...")
|
|
2122
2199
|
|