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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa19b12882a78f0dc868ca031b657d0500635ef08617f30a309640d0977e1db2
4
- data.tar.gz: 64534f19ed7e404810f15e1326bd5db3e82e3eacc8160b9ddc86ca59c5cc6d29
3
+ metadata.gz: 8abc47b88bb6730b91b0358f17328e477db2a16d56a315ac8a7189bc54e6471d
4
+ data.tar.gz: 68d3d6064d3fe7d709a1957baf245dbc12a7c02e6d9e81569f639ea140a5647a
5
5
  SHA512:
6
- metadata.gz: d2ea741174826fdb479b5a53fee673e5b8cc0b43bd0209cf5329bc1391db9f70420d58c3cc81f4bb40cce075e435ee5877b2e6ccef31ba820f1e41e943c5a085
7
- data.tar.gz: 0060573caeca1124dd219b1ea070bde2f7401135a6423d686dbe2504f5fc93e220bbfe8fa47143e7b7419c3f5ac13e6b28bb093b3714f90cabef6d195b855792
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 → CLI flagsenvironment variables
101
- `_config.yml` → built-in defaults.
104
+ Priority per file: author overrides → collection stylesCLI 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
- preview = author.get("preview")
586
- if not isinstance(preview, dict):
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 apply_author_overrides(settings: Settings, overrides: Dict[str, str]) -> Settings:
752
- """Per-file settings copy with the author's preview block applied on top."""
753
- if not overrides:
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
- style=overrides.get("style", settings.style),
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 and "thinking" in exc.message().lower():
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
- data = with_retries(
1545
- lambda: http_json(ANTHROPIC_API_URL, payload, self.headers(), timeout=900),
1546
- "Anthropic API (review)",
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
- # Author overrides apply only once an image/prompt is actually built.
2029
- file_settings = apply_author_overrides(
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 = apply_author_overrides(
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zer0ImageGenerator
4
- VERSION = "0.2.2"
4
+ VERSION = "0.4.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zer0-image-generator
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Amr Abdel