zer0-image-generator 0.1.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.
@@ -0,0 +1,2435 @@
1
+ #!/usr/bin/env python3
2
+ """zer0-image-generator — AI preview/social-image engine for any Jekyll site.
3
+
4
+ This single file IS the engine. It ships two ways and behaves identically in
5
+ both: vendored inside the `zer0-image-generator` Ruby gem (invoked as
6
+ `jekyll preview-images` from any Jekyll site) and standalone (curl this one
7
+ file anywhere and run it with python3). Lineage: extracted from the
8
+ zer0-mistakes theme's consolidated engine (`scripts/lib/preview_generator.py`,
9
+ PR bamr87/zer0-mistakes#296) and generalized — every zer0-ism is now a config
10
+ knob with zer0-compatible defaults:
11
+
12
+ knob _config.yml `preview_images:` key default
13
+ ----------------- ---------------------------------- -----------------
14
+ collections dir collections_dir (or Jekyll's own "" (site root,
15
+ top-level collections_dir) Jekyll default)
16
+ front-matter key front_matter_key preview
17
+ author overrides authors_file ("" disables) _data/authors.yml
18
+ collections collections (list, or "auto" to [posts]
19
+ discover from Jekyll's collections:)
20
+
21
+ Architecture — Claude ORCHESTRATES, an image model RENDERS:
22
+
23
+ stage role engine / credential
24
+ -------- -------------------------------------- ----------------------------------
25
+ analyze Claude reads the article and writes a CLAUDE_CODE_OAUTH_TOKEN →
26
+ vivid art-direction brief for the ANTHROPIC_AUTH_TOKEN →
27
+ renderer (prompt_engine: claude) ANTHROPIC_API_KEY → `claude` CLI
28
+ produce a raster image model renders the brief the selected provider (below)
29
+ review Claude looks at the produced image same Claude credential chain
30
+ (vision) and, if it misrepresents the
31
+ article, requests ONE refined
32
+ regeneration (review_engine: claude)
33
+
34
+ provider renderer credential
35
+ -------- -------------------------------------- ----------------------------------
36
+ openai gpt-image-2 / dall-e-3 (+ --enhance OPENAI_API_KEY
37
+ (default) via /v1/images/edits)
38
+ xai grok-2-image XAI_API_KEY
39
+ stability Stable Diffusion XL (v1 API) STABILITY_API_KEY
40
+ gemini gemini-2.5-flash-image GEMINI_API_KEY
41
+ local deterministic template SVG → PNG none (CI-safe; skips
42
+ analyze/review)
43
+
44
+ Claude never renders pixels itself (the Anthropic API has no image-generation
45
+ endpoint); with no Claude credential the analyze/review stages degrade
46
+ gracefully to the built-in template prompt with a warning — the renderer still
47
+ runs. The SVG toolkit (sanitizer + rsvg/inkscape/magick/Playwright rasterizer
48
+ chain) serves the zero-credential `local` provider.
49
+
50
+ Configuration priority (per file):
51
+ author preview overrides (authors_file) > CLI args > environment
52
+ variables > _config.yml `preview_images:` > built-in defaults
53
+
54
+ Usage (from a Jekyll site root — via the gem or the bare file):
55
+ jekyll preview-images --list-missing
56
+ python3 preview_generator.py --dry-run --verbose
57
+ python3 preview_generator.py --collection posts
58
+ python3 preview_generator.py -f _posts/my-post.md --force
59
+ python3 preview_generator.py --provider local -f <file>
60
+ python3 preview_generator.py --front-matter-key image --review none ...
61
+
62
+ Dependencies: Python 3.9+ stdlib + PyYAML (`pip3 install pyyaml`). No other
63
+ packages — HTTP goes through urllib, multipart bodies are hand-rolled.
64
+
65
+ Exit codes: 0 = success; 1 = validation failure or any per-file errors.
66
+ """
67
+
68
+ import argparse
69
+ import base64
70
+ import functools
71
+ import io
72
+ import json
73
+ import os
74
+ import re
75
+ import shutil
76
+ import signal
77
+ import subprocess
78
+ import sys
79
+ import threading
80
+ import time
81
+ import uuid
82
+ import urllib.error
83
+ import urllib.request
84
+ import zlib
85
+ import xml.etree.ElementTree as ET
86
+ from concurrent.futures import ThreadPoolExecutor, as_completed
87
+ from dataclasses import dataclass, field, replace
88
+ from pathlib import Path
89
+ from typing import Any, Dict, List, Optional, Tuple
90
+
91
+ try:
92
+ import yaml
93
+ except ImportError: # checked in ensure_yaml() after --help handling
94
+ yaml = None
95
+
96
+
97
+ # =============================================================================
98
+ # Constants
99
+ # =============================================================================
100
+
101
+ # Built-in fallbacks — used only when a key is absent from _config.yml, env,
102
+ # and CLI. Claude orchestration (analysis + review) is on by default and
103
+ # degrades gracefully without a Claude credential.
104
+ DEFAULTS: Dict[str, Any] = {
105
+ "enabled": True,
106
+ "provider": "openai",
107
+ "model": "", # empty → the active provider's default_model()
108
+ "size": "1536x1024",
109
+ "quality": "auto",
110
+ "style": (
111
+ "retro pixel art, 8-bit video game aesthetic, vibrant colors, "
112
+ "nostalgic, clean pixel graphics"
113
+ ),
114
+ "style_modifiers": (
115
+ "pixelated, retro gaming style, CRT screen glow effect, "
116
+ "limited color palette"
117
+ ),
118
+ "output_dir": "assets/images/previews",
119
+ "assets_prefix": "/assets",
120
+ "auto_prefix": True,
121
+ "collections": ["posts"], # or "auto": discover from Jekyll `collections:`
122
+ "collections_dir": "", # Jekyll's collections_dir (zer0-mistakes: "pages")
123
+ "front_matter_key": "preview", # jekyll-seo-tag users typically want "image"
124
+ "authors_file": "_data/authors.yml", # "" disables author overrides
125
+ "prompt_engine": "claude", # claude analyzes the article; falls back to template
126
+ "review_engine": "claude", # claude vision-reviews the render; `none` disables
127
+ "claude_model": "", # empty → DEFAULT_CLAUDE_MODEL
128
+ }
129
+
130
+ # Enhance mode (OpenAI /v1/images/edits — see OpenAIProvider.edit)
131
+ ENHANCE_DEFAULTS: Dict[str, str] = {
132
+ "model": "gpt-image-2",
133
+ "quality": "auto",
134
+ "fidelity": "high",
135
+ "format": "png",
136
+ }
137
+ DEFAULT_ENHANCE_PROMPT = (
138
+ "Improve this preview banner image: fix any misspelled, garbled, or "
139
+ "incorrect text so it reads clearly and accurately. Sharpen visual details "
140
+ "and improve composition while preserving the original art style, color "
141
+ "palette, and theme. Ensure the image is clean and professional."
142
+ )
143
+
144
+ # Anthropic wire constants — keep in sync with templates/deploy/chat-proxy/worker.js.
145
+ ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages"
146
+ ANTHROPIC_VERSION = "2023-06-01"
147
+ OAUTH_BETA = "oauth-2025-04-20"
148
+ # Claude Code OAuth tokens require the FIRST system block to identify as Claude
149
+ # Code, otherwise the API rejects the call with a misleading `rate_limit_error`.
150
+ CLAUDE_CODE_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI for Claude."
151
+ DEFAULT_CLAUDE_MODEL = "claude-opus-4-8"
152
+ CLAUDE_MAX_TOKENS = 16000 # non-streaming ceiling; SVG banners fit comfortably
153
+
154
+ # Model-name prefix → provider family. Used to ignore a configured/author model
155
+ # that belongs to a different vendor than the active provider (never send a
156
+ # request that is guaranteed to 400).
157
+ MODEL_FAMILIES: Dict[str, str] = {
158
+ "claude-": "claude",
159
+ "gpt-image-": "openai",
160
+ "dall-e-": "openai",
161
+ "grok-": "xai",
162
+ "stable-": "stability",
163
+ "sd3": "stability",
164
+ "gemini-": "gemini",
165
+ "imagen-": "gemini",
166
+ }
167
+
168
+ # Banner geometry for SVG-producing providers (claude, local).
169
+ SVG_WIDTH, SVG_HEIGHT = 1536, 1024
170
+
171
+ # Curated retro palettes: (sky/background, far, mid, near, accent, glow).
172
+ # Selected deterministically per slug so re-runs stay visually stable.
173
+ RETRO_PALETTES: List[List[str]] = [
174
+ ["#1a1a2e", "#16213e", "#0f3460", "#533483", "#e94560", "#f9ed69"],
175
+ ["#0d1b2a", "#1b263b", "#415a77", "#778da9", "#e0e1dd", "#ffb703"],
176
+ ["#2d132c", "#801336", "#c72c41", "#ee4540", "#f9d276", "#f4f4f4"],
177
+ ["#10002b", "#3c096c", "#7b2cbf", "#c77dff", "#e0aaff", "#72efdd"],
178
+ ["#001219", "#005f73", "#0a9396", "#94d2bd", "#e9d8a6", "#ee9b00"],
179
+ ["#03071e", "#370617", "#9d0208", "#dc2f02", "#f48c06", "#ffba08"],
180
+ ["#0b132b", "#1c2541", "#3a506b", "#5bc0be", "#6fffe9", "#ff6b6b"],
181
+ ["#232931", "#393e46", "#4ecca3", "#a5ecd7", "#eeeeee", "#f95959"],
182
+ ["#1f0a24", "#571089", "#ab51e3", "#f7aef8", "#b388eb", "#8093f1"],
183
+ ["#141e30", "#243b55", "#3c6382", "#82ccdd", "#f8c291", "#e55039"],
184
+ ]
185
+
186
+ COMPOSITION_VARIANTS: List[str] = [
187
+ "a low horizon with a huge rising sun disk banded by scanlines",
188
+ "layered diagonal mountain silhouettes receding into haze",
189
+ "a vaporwave perspective grid floor vanishing toward the horizon",
190
+ "floating terraced islands with cascading pixel waterfalls",
191
+ "a night starfield with a large ringed planet arcing across the frame",
192
+ "a stepped city skyline of blocky towers with lit windows",
193
+ "rolling desert dunes with a lone monolith and long shadows",
194
+ "an ocean of chunky pixel waves under drifting square clouds",
195
+ ]
196
+
197
+ # System prompt for Claude's ART-DIRECTOR role (prompt_engine: claude): read
198
+ # the article, then write the brief a raster image model will render. NOTE:
199
+ # "no text" matches the long-standing prompt rule of this feature — image
200
+ # models garble lettering.
201
+ ART_DIRECTOR_SYSTEM = """You are an art director for a technical blog. You will be given an article
202
+ (title, description, tags, an excerpt) plus mandatory style directions. Your
203
+ job is to design ONE preview banner image and describe it to an AI image
204
+ model.
205
+
206
+ Respond with ONLY the image-generation prompt — no preamble, no quotes, no
207
+ markdown. One vivid paragraph of at most 130 words that:
208
+ - captures the article's actual SUBJECT as a concrete visual metaphor or
209
+ scene (specific objects, actions and spatial arrangement — never a generic
210
+ 'technology background');
211
+ - specifies composition for a wide banner (what sits left/center/right,
212
+ foreground/background, focal point);
213
+ - weaves in the given art style and palette directions verbatim in spirit;
214
+ - states that the image must contain NO text, letters, words, numbers, logos
215
+ or UI copy of any kind."""
216
+
217
+ # System prompt for Claude's REVIEWER role (review_engine: claude): look at
218
+ # the rendered image and decide whether it represents the article.
219
+ REVIEWER_SYSTEM = """You are reviewing an AI-generated blog preview banner against the article it
220
+ illustrates. Judge three things: (1) does the image clearly evoke the
221
+ article's actual subject, (2) does it follow the requested art style, and
222
+ (3) is it free of text/lettering artifacts and visual glitches.
223
+
224
+ Respond with ONLY a JSON object, no markdown fences:
225
+ {"verdict": "approve" | "revise",
226
+ "critique": "<one or two sentences on what is wrong or right>",
227
+ "revised_prompt": "<empty when approving; otherwise a complete replacement
228
+ image-generation prompt (max 130 words) that fixes the problems while keeping
229
+ the required style and the no-text rule>"}
230
+
231
+ Approve unless the image genuinely misrepresents the subject, breaks the
232
+ style, or contains text/glitches — minor taste differences are not grounds
233
+ for revision."""
234
+
235
+ PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
236
+
237
+ # Pause after each successful non-dry generation (Bash parity: polite pacing
238
+ # between paid API calls). Tests set this to 0.
239
+ POST_GENERATION_SLEEP = 2.0
240
+
241
+
242
+ # =============================================================================
243
+ # Terminal output
244
+ # =============================================================================
245
+
246
+ class Colors:
247
+ RED = "\033[0;31m"
248
+ GREEN = "\033[0;32m"
249
+ YELLOW = "\033[1;33m"
250
+ BLUE = "\033[0;34m"
251
+ CYAN = "\033[0;36m"
252
+ PURPLE = "\033[0;35m"
253
+ BOLD = "\033[1m"
254
+ NC = "\033[0m"
255
+
256
+
257
+ VERBOSE = False
258
+ _log_file = None # type: Optional[Any]
259
+
260
+ _LEVEL_COLORS = {
261
+ "info": Colors.BLUE,
262
+ "step": Colors.CYAN,
263
+ "success": Colors.GREEN,
264
+ "warning": Colors.YELLOW,
265
+ "error": Colors.RED,
266
+ "debug": Colors.PURPLE,
267
+ }
268
+
269
+
270
+ def log(msg: str, level: str = "info") -> None:
271
+ color = _LEVEL_COLORS.get(level, Colors.NC)
272
+ stream = sys.stderr if level in ("warning", "error", "debug") else sys.stdout
273
+ print(f"{color}[{level.upper()}]{Colors.NC} {msg}", file=stream)
274
+ if _log_file:
275
+ stamp = time.strftime("%Y-%m-%d %H:%M:%S")
276
+ _log_file.write(f"{stamp} [{level.upper()}] {msg}\n")
277
+ _log_file.flush()
278
+
279
+
280
+ def info(msg: str) -> None:
281
+ log(msg, "info")
282
+
283
+
284
+ def step(msg: str) -> None:
285
+ log(msg, "step")
286
+
287
+
288
+ def success(msg: str) -> None:
289
+ log(msg, "success")
290
+
291
+
292
+ def warn(msg: str) -> None:
293
+ log(msg, "warning")
294
+
295
+
296
+ def debug(msg: str) -> None:
297
+ if VERBOSE:
298
+ log(msg, "debug")
299
+
300
+
301
+ def error_exit(msg: str) -> "NoReturn": # noqa: F821 - typing.NoReturn (3.9 compat)
302
+ log(msg, "error")
303
+ sys.exit(1)
304
+
305
+
306
+ def print_header(title: str) -> None:
307
+ line = "=" * 64
308
+ print(f"\n{Colors.CYAN}{line}{Colors.NC}")
309
+ print(f" {Colors.GREEN}{title}{Colors.NC}")
310
+ print(f"{Colors.CYAN}{line}{Colors.NC}\n")
311
+
312
+
313
+ # =============================================================================
314
+ # Environment / interrupts
315
+ # =============================================================================
316
+
317
+ _interrupted = False
318
+
319
+
320
+ def _signal_handler(signum, frame): # noqa: ARG001
321
+ global _interrupted
322
+ _interrupted = True
323
+ print(f"\n{Colors.YELLOW}⚠️ Interrupt received. Finishing current tasks...{Colors.NC}")
324
+
325
+
326
+ def _load_dotenv(start: Optional[Path] = None) -> None:
327
+ """Load .env from cwd (or `start`) up to 4 parents.
328
+
329
+ Non-empty exported env vars win over .env; an EMPTY env var is treated as
330
+ unset (docker/VS Code tasks forward `-e KEY=${env:KEY}` which materializes
331
+ empty strings that must not shadow a real value in .env).
332
+ """
333
+ search_dir = start or Path.cwd()
334
+ for _ in range(5):
335
+ env_file = search_dir / ".env"
336
+ if env_file.is_file():
337
+ try:
338
+ for line in env_file.read_text(encoding="utf-8").splitlines():
339
+ line = line.strip()
340
+ if not line or line.startswith("#") or "=" not in line:
341
+ continue
342
+ key, _, value = line.partition("=")
343
+ key = key.strip()
344
+ value = value.strip()
345
+ if value[:1] in ("'", '"') and value[-1:] == value[:1]:
346
+ value = value[1:-1] # matched surrounding quotes
347
+ else:
348
+ value = value.split(" #", 1)[0].rstrip() # inline comment
349
+ if key and not os.environ.get(key):
350
+ os.environ[key] = value
351
+ except OSError:
352
+ pass
353
+ return
354
+ if search_dir.parent == search_dir:
355
+ break
356
+ search_dir = search_dir.parent
357
+
358
+
359
+ def ensure_yaml() -> None:
360
+ if yaml is None:
361
+ error_exit(
362
+ "PyYAML is required (front matter, _config.yml and _data/authors.yml "
363
+ "parsing). Install it with ONE of:\n"
364
+ " pip3 install pyyaml\n"
365
+ " python3 -m pip install --user pyyaml\n"
366
+ " docker-compose exec jekyll pip3 install --break-system-packages pyyaml"
367
+ )
368
+
369
+
370
+ # =============================================================================
371
+ # HTTP layer (urllib only — no third-party HTTP deps)
372
+ # =============================================================================
373
+
374
+ class HttpStatusError(Exception):
375
+ """Non-2xx HTTP response, with parsed body + headers when possible."""
376
+
377
+ def __init__(self, status: int, body: bytes, url: str,
378
+ headers: Optional[Dict[str, str]] = None):
379
+ self.status = status
380
+ self.body = body
381
+ self.url = url
382
+ self.headers = {k.lower(): v for k, v in (headers or {}).items()}
383
+ super().__init__(f"HTTP {status} from {url}: {self.message()[:300]}")
384
+
385
+ def retry_after(self) -> float:
386
+ """Server-requested backoff: the Retry-After header (what Anthropic/
387
+ OpenAI/xAI actually send on 429), falling back to a JSON body field."""
388
+ try:
389
+ header = self.headers.get("retry-after")
390
+ if header:
391
+ return float(header)
392
+ except (TypeError, ValueError):
393
+ pass
394
+ data = self.json()
395
+ if isinstance(data, dict):
396
+ try:
397
+ return float(data.get("retry_after", 0) or 0)
398
+ except (TypeError, ValueError):
399
+ pass
400
+ return 0.0
401
+
402
+ def json(self) -> Optional[dict]:
403
+ try:
404
+ return json.loads(self.body.decode("utf-8", "replace"))
405
+ except (ValueError, UnicodeDecodeError):
406
+ return None
407
+
408
+ def message(self) -> str:
409
+ data = self.json()
410
+ if isinstance(data, dict):
411
+ err = data.get("error")
412
+ if isinstance(err, dict) and err.get("message"):
413
+ return str(err["message"])
414
+ if isinstance(err, str):
415
+ return err
416
+ if data.get("detail"):
417
+ return str(data["detail"])
418
+ return self.body.decode("utf-8", "replace")[:500]
419
+
420
+
421
+ def http_request(
422
+ url: str,
423
+ method: str = "GET",
424
+ headers: Optional[Dict[str, str]] = None,
425
+ data: Optional[bytes] = None,
426
+ timeout: int = 120,
427
+ ) -> Tuple[int, Dict[str, str], bytes]:
428
+ req = urllib.request.Request(url, data=data, method=method)
429
+ for key, value in (headers or {}).items():
430
+ req.add_header(key, value)
431
+ try:
432
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
433
+ return resp.status, dict(resp.headers.items()), resp.read()
434
+ except urllib.error.HTTPError as exc:
435
+ body = exc.read() if exc.fp else b""
436
+ raise HttpStatusError(exc.code, body, url,
437
+ dict(exc.headers.items()) if exc.headers else None) from None
438
+
439
+
440
+ def http_json(
441
+ url: str, payload: dict, headers: Dict[str, str], timeout: int = 900
442
+ ) -> dict:
443
+ body = json.dumps(payload).encode("utf-8")
444
+ hdrs = {"Content-Type": "application/json", **headers}
445
+ _, _, raw = http_request(url, "POST", hdrs, body, timeout)
446
+ return json.loads(raw.decode("utf-8"))
447
+
448
+
449
+ def build_multipart(
450
+ fields: Dict[str, str], files: List[Tuple[str, str, bytes, str]]
451
+ ) -> Tuple[bytes, str]:
452
+ """Encode multipart/form-data. files: (field, filename, content, mime)."""
453
+ boundary = f"----zer0-{uuid.uuid4().hex}"
454
+ buf = io.BytesIO()
455
+ for name, value in fields.items():
456
+ buf.write(f"--{boundary}\r\n".encode())
457
+ buf.write(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
458
+ buf.write(str(value).encode("utf-8"))
459
+ buf.write(b"\r\n")
460
+ for name, filename, content, mime in files:
461
+ buf.write(f"--{boundary}\r\n".encode())
462
+ buf.write(
463
+ f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'.encode()
464
+ )
465
+ buf.write(f"Content-Type: {mime}\r\n\r\n".encode())
466
+ buf.write(content)
467
+ buf.write(b"\r\n")
468
+ buf.write(f"--{boundary}--\r\n".encode())
469
+ return buf.getvalue(), f"multipart/form-data; boundary={boundary}"
470
+
471
+
472
+ def http_multipart(
473
+ url: str,
474
+ fields: Dict[str, str],
475
+ files: List[Tuple[str, str, bytes, str]],
476
+ headers: Dict[str, str],
477
+ timeout: int = 900,
478
+ ) -> dict:
479
+ body, content_type = build_multipart(fields, files)
480
+ hdrs = {"Content-Type": content_type, **headers}
481
+ _, _, raw = http_request(url, "POST", hdrs, body, timeout)
482
+ return json.loads(raw.decode("utf-8"))
483
+
484
+
485
+ def download_to(url: str, dest: Path, timeout: int = 120) -> None:
486
+ _, _, raw = http_request(url, "GET", {}, None, timeout)
487
+ dest.write_bytes(raw)
488
+
489
+
490
+ RETRYABLE_STATUSES = {429, 500, 502, 503, 529}
491
+
492
+
493
+ def with_retries(fn, what: str, attempts: int = 4):
494
+ """Run fn(); retry transport errors and retryable HTTP statuses.
495
+
496
+ Backoff 2s/4s/8s (or the server's Retry-After when larger, capped 60s).
497
+ """
498
+ delay = 2.0
499
+ for attempt in range(1, attempts + 1):
500
+ try:
501
+ return fn()
502
+ except HttpStatusError as exc:
503
+ if exc.status not in RETRYABLE_STATUSES or attempt == attempts:
504
+ raise
505
+ wait = min(max(delay, exc.retry_after()), 60.0)
506
+ warn(f"{what}: HTTP {exc.status}, retrying in {wait:.0f}s "
507
+ f"(attempt {attempt}/{attempts})")
508
+ except (urllib.error.URLError, TimeoutError, ConnectionError, OSError) as exc:
509
+ if attempt == attempts:
510
+ raise
511
+ wait = delay
512
+ warn(f"{what}: {exc.__class__.__name__}: {exc}; retrying in {wait:.0f}s "
513
+ f"(attempt {attempt}/{attempts})")
514
+ time.sleep(wait)
515
+ delay *= 2
516
+
517
+
518
+ # =============================================================================
519
+ # Config layer
520
+ # =============================================================================
521
+
522
+ def find_project_root() -> Path:
523
+ """Site root: two parents above this file when vendored at
524
+ <site>/scripts/lib/ (curl layout); else walk up from cwd — the gem layout,
525
+ where the engine lives far from the site and `jekyll` runs at the root."""
526
+ script_root = Path(__file__).resolve().parent.parent.parent
527
+ if (script_root / "_config.yml").is_file():
528
+ return script_root
529
+ probe = Path.cwd()
530
+ for _ in range(6):
531
+ if (probe / "_config.yml").is_file():
532
+ return probe
533
+ if probe.parent == probe:
534
+ break
535
+ probe = probe.parent
536
+ return script_root
537
+
538
+
539
+ def load_yaml_file(path: Path) -> Dict[str, Any]:
540
+ try:
541
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
542
+ return data if isinstance(data, dict) else {}
543
+ except FileNotFoundError:
544
+ return {}
545
+ except Exception as exc: # malformed YAML must not kill the whole run
546
+ warn(f"Could not parse {path.name}: {exc}")
547
+ return {}
548
+
549
+
550
+ def read_config(project_root: Path) -> Dict[str, Any]:
551
+ """The site's full _config.yml — resolve_settings extracts the
552
+ `preview_images:` block plus the top-level Jekyll keys it honors
553
+ (`collections_dir`, `collections`)."""
554
+ return load_yaml_file(project_root / "_config.yml")
555
+
556
+
557
+ def read_authors(project_root: Path, authors_file: str) -> Dict[str, Any]:
558
+ """Author-override map. `authors_file` is project-root-relative; an empty
559
+ value disables the feature (universal sites rarely have authors.yml)."""
560
+ if not (authors_file or "").strip():
561
+ return {}
562
+ return load_yaml_file(project_root / authors_file.strip().lstrip("/"))
563
+
564
+
565
+ def author_preview_overrides(authors: Dict[str, Any], author_key: Any) -> Dict[str, str]:
566
+ """`preview:` override block for an author key (style/style_modifiers/size/
567
+ quality/model). `author:` may be a list or mapping in some posts — only a
568
+ plain string key can index authors.yml; anything else has no override."""
569
+ if not isinstance(author_key, str) or not author_key:
570
+ return {}
571
+ author = authors.get(author_key)
572
+ if not isinstance(author, dict):
573
+ return {}
574
+ preview = author.get("preview")
575
+ if not isinstance(preview, dict):
576
+ return {}
577
+ return {
578
+ key: str(value).strip()
579
+ for key, value in preview.items()
580
+ if key in ("style", "style_modifiers", "size", "quality", "model")
581
+ and value is not None and str(value).strip()
582
+ }
583
+
584
+
585
+ def _env_flag(name: str) -> bool:
586
+ return os.environ.get(name, "").strip().lower() == "true"
587
+
588
+
589
+ @dataclass
590
+ class Settings:
591
+ """Fully-resolved run settings (before per-file author overrides)."""
592
+
593
+ provider: str = DEFAULTS["provider"]
594
+ model: str = DEFAULTS["model"]
595
+ size: str = DEFAULTS["size"]
596
+ quality: str = DEFAULTS["quality"]
597
+ style: str = DEFAULTS["style"]
598
+ style_modifiers: str = DEFAULTS["style_modifiers"]
599
+ output_dir: str = DEFAULTS["output_dir"]
600
+ assets_prefix: str = DEFAULTS["assets_prefix"]
601
+ auto_prefix: bool = DEFAULTS["auto_prefix"]
602
+ enabled: bool = True
603
+ collections: List[str] = field(default_factory=lambda: list(DEFAULTS["collections"]))
604
+ collections_dir: str = DEFAULTS["collections_dir"]
605
+ front_matter_key: str = DEFAULTS["front_matter_key"]
606
+ authors_file: str = DEFAULTS["authors_file"]
607
+ prompt_engine: str = DEFAULTS["prompt_engine"]
608
+ review_engine: str = DEFAULTS["review_engine"]
609
+ claude_model: str = DEFAULTS["claude_model"]
610
+
611
+ dry_run: bool = False
612
+ verbose: bool = False
613
+ force: bool = False
614
+ list_only: bool = False
615
+ parallel: int = 4
616
+ batch: int = 0
617
+
618
+ file: str = ""
619
+ collection: str = ""
620
+
621
+ enhance: bool = False
622
+ enhance_prompt: str = ""
623
+ enhance_model: str = ENHANCE_DEFAULTS["model"]
624
+ enhance_quality: str = ENHANCE_DEFAULTS["quality"]
625
+ enhance_fidelity: str = ENHANCE_DEFAULTS["fidelity"]
626
+ enhance_format: str = ENHANCE_DEFAULTS["format"]
627
+
628
+ rasterizer: str = "auto"
629
+ provider_explicit: bool = False
630
+
631
+
632
+ def resolve_settings(args: argparse.Namespace, config: Dict[str, Any]) -> Settings:
633
+ """Merge CLI > env > _config.yml `preview_images:` > DEFAULTS into a
634
+ Settings object. `config` is the site's FULL _config.yml dict — the block
635
+ is extracted here, and two top-level Jekyll keys are honored as fallbacks:
636
+ `collections_dir` and (for `collections: auto`) the `collections:` map."""
637
+
638
+ site = config.get("preview_images")
639
+ if not isinstance(site, dict):
640
+ site = {}
641
+
642
+ def cfg(key: str, default: Any) -> Any:
643
+ value = site.get(key)
644
+ return default if value is None else value
645
+
646
+ def pick(cli_value: Optional[str], env_name: str, cfg_key: str) -> str:
647
+ if cli_value is not None:
648
+ return cli_value
649
+ env_value = os.environ.get(env_name)
650
+ if env_value:
651
+ return env_value
652
+ return str(cfg(cfg_key, DEFAULTS[cfg_key]))
653
+
654
+ # collections_dir: block override > Jekyll's own top-level key > default.
655
+ collections_dir = cfg(
656
+ "collections_dir", config.get("collections_dir") or DEFAULTS["collections_dir"]
657
+ )
658
+ if args.collections_dir is not None:
659
+ collections_dir = args.collections_dir
660
+
661
+ collections = cfg("collections", DEFAULTS["collections"])
662
+ if collections == "auto" or (
663
+ isinstance(collections, list) and "auto" in collections
664
+ ):
665
+ # Discover from Jekyll's own collections: map; posts always exists.
666
+ declared = config.get("collections")
667
+ names = list(declared.keys()) if isinstance(declared, dict) else []
668
+ collections = ["posts"] + [n for n in names if n != "posts"]
669
+ if not isinstance(collections, list) or not collections:
670
+ collections = list(DEFAULTS["collections"])
671
+
672
+ parallel_env = os.environ.get("MAX_PARALLEL", "")
673
+ parallel = args.parallel if args.parallel is not None else (
674
+ int(parallel_env) if parallel_env.isdigit() else 4
675
+ )
676
+
677
+ settings = Settings(
678
+ provider=pick(args.provider, "AI_PROVIDER", "provider"),
679
+ model=pick(args.model, "IMAGE_MODEL", "model"),
680
+ size=pick(None, "IMAGE_SIZE", "size"),
681
+ quality=pick(None, "IMAGE_QUALITY", "quality"),
682
+ style=pick(args.style, "IMAGE_STYLE", "style"),
683
+ style_modifiers=pick(None, "IMAGE_STYLE_MODIFIERS", "style_modifiers"),
684
+ output_dir=pick(args.output_dir, "OUTPUT_DIR", "output_dir"),
685
+ assets_prefix=(
686
+ args.assets_prefix if args.assets_prefix is not None
687
+ else str(cfg("assets_prefix", DEFAULTS["assets_prefix"]))
688
+ ),
689
+ auto_prefix=(
690
+ False if args.no_auto_prefix
691
+ else bool(cfg("auto_prefix", DEFAULTS["auto_prefix"]))
692
+ ),
693
+ enabled=bool(cfg("enabled", True)),
694
+ collections=[str(c) for c in collections],
695
+ collections_dir=str(collections_dir or "").strip().strip("/"),
696
+ front_matter_key=(
697
+ args.front_matter_key
698
+ or str(cfg("front_matter_key", DEFAULTS["front_matter_key"])).strip()
699
+ or DEFAULTS["front_matter_key"]
700
+ ),
701
+ authors_file=(
702
+ args.authors_file if args.authors_file is not None
703
+ else str(cfg("authors_file", DEFAULTS["authors_file"]) or "")
704
+ ),
705
+ prompt_engine=pick(args.prompt_engine, "PROMPT_ENGINE", "prompt_engine"),
706
+ review_engine=pick(args.review, "REVIEW_ENGINE", "review_engine"),
707
+ claude_model=str(cfg("claude_model", "") or ""),
708
+ dry_run=args.dry_run or _env_flag("DRY_RUN"),
709
+ verbose=args.verbose or _env_flag("VERBOSE"),
710
+ force=args.force or _env_flag("FORCE"),
711
+ list_only=args.list_missing or _env_flag("LIST_ONLY"),
712
+ parallel=parallel,
713
+ batch=args.batch or 0,
714
+ file=args.file or "",
715
+ collection=args.collection or "",
716
+ enhance=args.enhance or _env_flag("ENHANCE"),
717
+ enhance_prompt=args.enhance_prompt or "",
718
+ enhance_model=(
719
+ args.enhance_model or os.environ.get("ENHANCE_MODEL")
720
+ or ENHANCE_DEFAULTS["model"]
721
+ ),
722
+ enhance_quality=(
723
+ args.enhance_quality or os.environ.get("ENHANCE_QUALITY")
724
+ or ENHANCE_DEFAULTS["quality"]
725
+ ),
726
+ enhance_fidelity=(
727
+ args.enhance_fidelity or os.environ.get("ENHANCE_FIDELITY")
728
+ or ENHANCE_DEFAULTS["fidelity"]
729
+ ),
730
+ enhance_format=(
731
+ args.enhance_format or os.environ.get("ENHANCE_FORMAT")
732
+ or ENHANCE_DEFAULTS["format"]
733
+ ),
734
+ rasterizer=args.rasterizer or "auto",
735
+ provider_explicit=args.provider is not None or bool(os.environ.get("AI_PROVIDER")),
736
+ )
737
+ return settings
738
+
739
+
740
+ def apply_author_overrides(settings: Settings, overrides: Dict[str, str]) -> Settings:
741
+ """Per-file settings copy with the author's preview block applied on top."""
742
+ if not overrides:
743
+ return settings
744
+ return replace(
745
+ settings,
746
+ style=overrides.get("style", settings.style),
747
+ style_modifiers=overrides.get("style_modifiers", settings.style_modifiers),
748
+ size=overrides.get("size", settings.size),
749
+ quality=overrides.get("quality", settings.quality),
750
+ model=overrides.get("model", settings.model),
751
+ )
752
+
753
+
754
+ def model_family(model: str) -> Optional[str]:
755
+ m = (model or "").strip().lower()
756
+ for prefix, family in MODEL_FAMILIES.items():
757
+ if m.startswith(prefix):
758
+ return family
759
+ return None
760
+
761
+
762
+ def effective_model(settings: Settings, provider: "Provider") -> str:
763
+ """Configured model when it belongs to the provider's family, else the
764
+ provider default (with a warning) — never emit a guaranteed-400 request."""
765
+ model = (settings.model or "").strip()
766
+ if not model:
767
+ return provider.default_model()
768
+ family = model_family(model)
769
+ if family is not None and family != provider.name:
770
+ warn(
771
+ f"Model '{model}' belongs to the '{family}' family; using "
772
+ f"{provider.name} default '{provider.default_model()}' instead"
773
+ )
774
+ return provider.default_model()
775
+ return model
776
+
777
+
778
+ # =============================================================================
779
+ # Front-matter layer
780
+ # =============================================================================
781
+
782
+ @dataclass
783
+ class ContentFile:
784
+ path: Path
785
+ title: str
786
+ description: str
787
+ categories: str
788
+ preview: Optional[str]
789
+ author: Any
790
+ content: str
791
+ front_matter: Dict[str, Any]
792
+
793
+
794
+ _FM_OPEN = re.compile(r"\A---[ \t]*\r?\n")
795
+
796
+
797
+ def split_front_matter(text: str) -> Optional[Tuple[int, int, str]]:
798
+ """Return (fm_start, fm_end, fm_text) — offsets of the raw front-matter
799
+ body between the opening and closing `---` fences — or None."""
800
+ open_match = _FM_OPEN.match(text)
801
+ if not open_match:
802
+ return None
803
+ fm_start = open_match.end()
804
+ close = re.compile(r"^---[ \t]*\r?$", re.M).search(text, fm_start)
805
+ if not close:
806
+ return None
807
+ return fm_start, close.start(), text[fm_start:close.start()]
808
+
809
+
810
+ def parse_front_matter(path: Path, fm_key: str = "preview") -> Optional[ContentFile]:
811
+ try:
812
+ text = path.read_text(encoding="utf-8")
813
+ except (OSError, UnicodeDecodeError) as exc:
814
+ warn(f"Failed to read {path}: {exc}")
815
+ return None
816
+
817
+ parts = split_front_matter(text)
818
+ if not parts:
819
+ debug(f"No front matter found in: {path}")
820
+ return None
821
+ fm_start, fm_end = parts[0], parts[1]
822
+
823
+ try:
824
+ data = yaml.safe_load(parts[2])
825
+ except yaml.YAMLError as exc:
826
+ warn(f"Failed to parse YAML in {path}: {exc}")
827
+ return None
828
+ if not isinstance(data, dict):
829
+ return None
830
+
831
+ categories = data.get("categories", [])
832
+ if isinstance(categories, str):
833
+ categories = [categories]
834
+ if not isinstance(categories, list):
835
+ categories = []
836
+
837
+ body_start = text.find("\n", fm_end)
838
+ body = text[body_start + 1:] if body_start != -1 else ""
839
+
840
+ return ContentFile(
841
+ path=path,
842
+ title=str(data.get("title") or ""),
843
+ description=str(data.get("description") or ""),
844
+ categories=", ".join(str(c) for c in categories),
845
+ preview=data.get(fm_key) if isinstance(data.get(fm_key), str) else None,
846
+ author=data.get("author"),
847
+ content=body,
848
+ front_matter=data,
849
+ )
850
+
851
+
852
+ def update_front_matter(path: Path, preview_path: str, dry_run: bool = False,
853
+ key: str = "preview") -> bool:
854
+ """Set `<key>:` inside the front-matter block ONLY (first match).
855
+
856
+ Replaces an existing `<key>:` line, else inserts after the first
857
+ `description:` line, else after the first `title:` line. Everything outside
858
+ the front-matter block is preserved byte-for-byte (the former sed/regex
859
+ implementations operated file-wide and could corrupt body lines that start
860
+ with the key). Writes a transient `.bak`, replaces atomically, removes
861
+ the `.bak` on success.
862
+ """
863
+ if dry_run:
864
+ info(f"[DRY RUN] Would update {key} in {path} to: {preview_path}")
865
+ return True
866
+
867
+ try:
868
+ # Bytes round-trip: Path.read_text() would translate CRLF → LF.
869
+ text = path.read_bytes().decode("utf-8")
870
+ except (OSError, UnicodeDecodeError) as exc:
871
+ warn(f"Failed to read {path}: {exc}")
872
+ return False
873
+
874
+ parts = split_front_matter(text)
875
+ if not parts:
876
+ warn(f"No front matter block in {path}; not updating")
877
+ return False
878
+ fm_start, fm_end, fm_text = parts
879
+
880
+ eol = "\r\n" if "\r\n" in fm_text else "\n"
881
+ lines = fm_text.splitlines(keepends=True)
882
+
883
+ def find_key(prefix: str) -> int:
884
+ for idx, line in enumerate(lines):
885
+ if line.startswith(prefix):
886
+ return idx
887
+ return -1
888
+
889
+ def end_of_block(idx: int) -> int:
890
+ """Index just past a key line and any indented continuation lines
891
+ (folded/literal scalars, nested maps)."""
892
+ j = idx + 1
893
+ while j < len(lines) and (lines[j].startswith((" ", "\t"))
894
+ or lines[j].strip() == ""):
895
+ # A blank line only continues the block if an indented line follows.
896
+ if lines[j].strip() == "" and not (
897
+ j + 1 < len(lines) and lines[j + 1].startswith((" ", "\t"))
898
+ ):
899
+ break
900
+ j += 1
901
+ return j
902
+
903
+ preview_idx = find_key(f"{key}:")
904
+ if preview_idx != -1:
905
+ line_eol = "\r\n" if lines[preview_idx].endswith("\r\n") else (
906
+ "\n" if lines[preview_idx].endswith("\n") else ""
907
+ )
908
+ lines[preview_idx] = f"{key}: {preview_path}{line_eol}"
909
+ else:
910
+ anchor = find_key("description:")
911
+ if anchor == -1:
912
+ anchor = find_key("title:")
913
+ if anchor == -1:
914
+ warn(f"No {key}:/description:/title: anchor in {path} front matter")
915
+ return False
916
+ insert_at = end_of_block(anchor)
917
+ if not lines[anchor].endswith("\n") and insert_at == anchor + 1:
918
+ lines[anchor] += eol # anchor was the final unterminated line
919
+ lines.insert(insert_at, f"{key}: {preview_path}{eol}")
920
+
921
+ new_text = text[:fm_start] + "".join(lines) + text[fm_end:]
922
+
923
+ backup = path.with_name(path.name + ".bak")
924
+ tmp = path.with_name(path.name + ".tmp~")
925
+ try:
926
+ shutil.copy2(path, backup)
927
+ tmp.write_bytes(new_text.encode("utf-8"))
928
+ os.replace(tmp, path)
929
+ backup.unlink(missing_ok=True)
930
+ except OSError as exc:
931
+ warn(f"Failed to update front matter in {path}: {exc}")
932
+ tmp.unlink(missing_ok=True)
933
+ return False
934
+
935
+ success(f"Updated front matter with {key}: {preview_path}")
936
+ return True
937
+
938
+
939
+ def generate_filename(title: str) -> str:
940
+ """Slug identical to the historical Bash chain (trim before the 50-cut)."""
941
+ slug = re.sub(r"[^a-z0-9]", "-", title.lower())
942
+ slug = re.sub(r"-+", "-", slug).strip("-")
943
+ return slug[:50]
944
+
945
+
946
+ def normalize_preview_path(preview: Optional[str], settings: Settings) -> Optional[str]:
947
+ """Front-matter path → site-absolute path (/assets/... form) for existence
948
+ checks. External URLs pass through unchanged."""
949
+ if not preview:
950
+ return preview
951
+ if preview.startswith(("http://", "https://")):
952
+ return preview
953
+ # Substring (not prefix) check is deliberate: it mirrors the Liquid
954
+ # `contains` logic in components/preview-image.html and content/seo.html —
955
+ # the engine's idea of "already prefixed" must match what the site renders.
956
+ if settings.auto_prefix and settings.assets_prefix not in preview:
957
+ return f"{settings.assets_prefix}{preview}"
958
+ return preview
959
+
960
+
961
+ def check_preview_exists(preview: Optional[str], settings: Settings, root: Path) -> bool:
962
+ if not preview:
963
+ return False
964
+ # External URLs count as present (matches the Liquid component + SEO tags;
965
+ # the old Bash engine would silently regenerate over them).
966
+ if preview.startswith(("http://", "https://")):
967
+ return True
968
+ normalized = normalize_preview_path(preview, settings) or ""
969
+ clean = normalized.lstrip("/")
970
+ candidates = [root / clean, root / "assets" / clean]
971
+ return any(p.is_file() for p in candidates)
972
+
973
+
974
+ def preview_front_matter_path(settings: Settings, filename: str) -> str:
975
+ """The value written to front matter — WITHOUT the assets prefix (Liquid
976
+ adds it): assets/images/previews → /images/previews/<filename>."""
977
+ out = settings.output_dir.strip("/")
978
+ prefix = settings.assets_prefix.strip("/")
979
+ if prefix and (out == prefix or out.startswith(prefix + "/")):
980
+ out = out[len(prefix):].strip("/")
981
+ return f"/{out}/{filename}" if out else f"/{filename}"
982
+
983
+
984
+ def find_preview_image(preview: Optional[str], settings: Settings, root: Path) -> Optional[Path]:
985
+ """Locate an existing preview image on disk (for --enhance)."""
986
+ if not preview or preview.startswith(("http://", "https://")):
987
+ return None
988
+ clean = preview.lstrip("/")
989
+ candidates = [
990
+ root / clean,
991
+ root / "assets" / clean,
992
+ root / settings.output_dir / Path(clean).name,
993
+ ]
994
+ for candidate in candidates:
995
+ if candidate.is_file():
996
+ return candidate
997
+ return None
998
+
999
+
1000
+ # =============================================================================
1001
+ # Prompt layer
1002
+ # =============================================================================
1003
+
1004
+ def build_prompt(cf: ContentFile, settings: Settings) -> str:
1005
+ """Template prompt — wording carried over from the original engine."""
1006
+ parts = [f"Create a blog preview banner image for an article titled '{cf.title}'."]
1007
+ if cf.description:
1008
+ parts.append(f"The article is about: {cf.description}.")
1009
+ if cf.categories:
1010
+ parts.append(f"Categories: {cf.categories}.")
1011
+ excerpt = re.sub(r"\s+", " ", cf.content[:500]).strip()
1012
+ if excerpt:
1013
+ parts.append(f"Key themes from content: {excerpt}")
1014
+ parts.append(f"Art style: {settings.style}.")
1015
+ if settings.style_modifiers:
1016
+ parts.append(f"Additional style: {settings.style_modifiers}.")
1017
+ parts.append(
1018
+ "The image should be suitable as a wide blog header/banner image with "
1019
+ "clean composition. No text or words in the image."
1020
+ )
1021
+ return " ".join(parts)
1022
+
1023
+
1024
+ def build_enhance_prompt(cf: ContentFile, settings: Settings) -> str:
1025
+ prompt = settings.enhance_prompt or DEFAULT_ENHANCE_PROMPT
1026
+ if cf.title:
1027
+ prompt += f" Context: This is a preview banner for an article titled '{cf.title}'."
1028
+ if cf.description:
1029
+ prompt += f" Article topic: {cf.description}."
1030
+ prompt += f" Maintain the {settings.style} artistic style."
1031
+ return prompt
1032
+
1033
+
1034
+ def claude_model_for(settings: Settings) -> str:
1035
+ return settings.claude_model or DEFAULT_CLAUDE_MODEL
1036
+
1037
+
1038
+ def claude_article_brief(client: "AnthropicClient", cf: ContentFile,
1039
+ settings: Settings, base_prompt: str) -> str:
1040
+ """Analyze stage (prompt_engine: claude): Claude reads the article and
1041
+ writes the art-direction brief the renderer will receive. Falls back to
1042
+ the template prompt on any failure — analysis is an enhancement layer,
1043
+ never a hard dependency."""
1044
+ excerpt = re.sub(r"\s+", " ", cf.content[:1500]).strip()
1045
+ article = (
1046
+ f"ARTICLE\nTitle: {cf.title}\n"
1047
+ f"Description: {cf.description or '(none)'}\n"
1048
+ f"Categories/tags: {cf.categories or '(none)'}\n"
1049
+ f"Excerpt: {excerpt or '(none)'}\n\n"
1050
+ f"MANDATORY STYLE DIRECTIONS\nArt style: {settings.style}\n"
1051
+ f"Style modifiers: {settings.style_modifiers or '(none)'}\n\n"
1052
+ "Write the image-generation prompt now."
1053
+ )
1054
+ try:
1055
+ text = client.complete(
1056
+ ART_DIRECTOR_SYSTEM, article,
1057
+ model=claude_model_for(settings), max_tokens=2048,
1058
+ ).strip()
1059
+ if text:
1060
+ debug(f"Claude art-direction brief: {text[:300]}...")
1061
+ return text
1062
+ warn("Claude analysis returned empty text; using template prompt")
1063
+ except Exception as exc:
1064
+ warn(f"Claude analysis failed ({exc}); using template prompt")
1065
+ return base_prompt
1066
+
1067
+
1068
+ def _extract_json_object(text: str) -> Optional[dict]:
1069
+ start, end = text.find("{"), text.rfind("}")
1070
+ if start == -1 or end <= start:
1071
+ return None
1072
+ try:
1073
+ data = json.loads(text[start:end + 1])
1074
+ return data if isinstance(data, dict) else None
1075
+ except ValueError:
1076
+ return None
1077
+
1078
+
1079
+ def claude_review_image(client: "AnthropicClient", image_path: Path,
1080
+ cf: ContentFile, prompt: str,
1081
+ settings: Settings) -> Tuple[bool, str, str]:
1082
+ """Review stage (review_engine: claude): Claude inspects the rendered
1083
+ banner. Returns (approved, critique, revised_prompt). Any failure counts
1084
+ as approval — review must never block generation."""
1085
+ context = (
1086
+ f"ARTICLE\nTitle: {cf.title}\nDescription: {cf.description or '(none)'}\n"
1087
+ f"Categories/tags: {cf.categories or '(none)'}\n\n"
1088
+ f"REQUIRED STYLE\n{settings.style}"
1089
+ + (f"; {settings.style_modifiers}" if settings.style_modifiers else "")
1090
+ + f"\n\nPROMPT THE IMAGE WAS GENERATED FROM\n{prompt}\n\n"
1091
+ "Review the image above against the article and style. JSON only."
1092
+ )
1093
+ try:
1094
+ text = client.complete_vision(
1095
+ REVIEWER_SYSTEM, context, image_path,
1096
+ model=claude_model_for(settings),
1097
+ )
1098
+ data = _extract_json_object(text)
1099
+ if not data:
1100
+ warn("Claude review returned no parseable verdict; keeping image")
1101
+ return True, "", ""
1102
+ verdict = str(data.get("verdict", "approve")).lower()
1103
+ critique = str(data.get("critique", "")).strip()
1104
+ revised = str(data.get("revised_prompt", "")).strip()
1105
+ if verdict == "revise" and revised:
1106
+ return False, critique, revised
1107
+ return True, critique, ""
1108
+ except Exception as exc:
1109
+ warn(f"Claude review failed ({exc}); keeping image")
1110
+ return True, "", ""
1111
+
1112
+
1113
+ # =============================================================================
1114
+ # SVG toolkit
1115
+ # =============================================================================
1116
+
1117
+ SVG_NS = "http://www.w3.org/2000/svg"
1118
+ XLINK_NS = "http://www.w3.org/1999/xlink"
1119
+
1120
+ _BANNED_SVG_ELEMENTS = {"script", "foreignObject", "iframe", "audio", "video", "image"}
1121
+
1122
+
1123
+ class SvgError(Exception):
1124
+ pass
1125
+
1126
+
1127
+ def seed_for(slug: str) -> int:
1128
+ return zlib.crc32(slug.encode("utf-8"))
1129
+
1130
+
1131
+ def palette_for(seed: int) -> List[str]:
1132
+ return RETRO_PALETTES[seed % len(RETRO_PALETTES)]
1133
+
1134
+
1135
+ def composition_for(seed: int) -> str:
1136
+ return COMPOSITION_VARIANTS[(seed >> 8) % len(COMPOSITION_VARIANTS)]
1137
+
1138
+
1139
+ def _localname(tag: str) -> str:
1140
+ return tag.rsplit("}", 1)[-1] if "}" in tag else tag
1141
+
1142
+
1143
+ _URL_REF = re.compile(r"url\(\s*(?!#|'#|\"#)[^)]*\)", re.I)
1144
+
1145
+
1146
+ def _scrub_style_text(value: str) -> str:
1147
+ value = re.sub(r"@import[^;]*;?", "", value, flags=re.I)
1148
+ return _URL_REF.sub("none", value)
1149
+
1150
+
1151
+ def sanitize_svg(svg_text: str) -> Tuple[str, List[str]]:
1152
+ """Parse + sanitize model-produced SVG. Raises SvgError when unusable.
1153
+
1154
+ Strips scripts/foreignObject/external references/event handlers; forces the
1155
+ banner viewBox/width/height. Returns (clean_svg, warnings).
1156
+ """
1157
+ warnings: List[str] = []
1158
+ # Reject DTDs outright: entity declarations enable expansion attacks
1159
+ # (billion-laughs) and external references; legit banner SVG needs neither.
1160
+ if re.search(r"<!\s*(DOCTYPE|ENTITY)", svg_text, re.I):
1161
+ raise SvgError("SVG contains a DOCTYPE/ENTITY declaration (rejected)")
1162
+ try:
1163
+ root = ET.fromstring(svg_text)
1164
+ except ET.ParseError as exc:
1165
+ raise SvgError(f"SVG does not parse: {exc}") from None
1166
+ if _localname(root.tag) != "svg":
1167
+ raise SvgError(f"Root element is <{_localname(root.tag)}>, not <svg>")
1168
+
1169
+ def scrub(element: ET.Element) -> None:
1170
+ for child in list(element):
1171
+ name = _localname(child.tag)
1172
+ if name in _BANNED_SVG_ELEMENTS:
1173
+ element.remove(child)
1174
+ warnings.append(f"removed <{name}>")
1175
+ continue
1176
+ scrub(child)
1177
+
1178
+ for attr in list(element.attrib):
1179
+ local = _localname(attr)
1180
+ value = element.attrib[attr]
1181
+ if local.lower().startswith("on"):
1182
+ del element.attrib[attr]
1183
+ warnings.append(f"removed {local} handler")
1184
+ elif local == "href" and not value.startswith("#"):
1185
+ del element.attrib[attr]
1186
+ warnings.append("removed external href")
1187
+ elif "url(" in value.lower() or "@import" in value.lower():
1188
+ # Covers style= AND presentation attributes (fill, stroke,
1189
+ # filter, mask, clip-path, …) — url() must stay #-local.
1190
+ cleaned = _scrub_style_text(value)
1191
+ if cleaned != value:
1192
+ element.attrib[attr] = cleaned
1193
+ warnings.append(f"scrubbed url() in {local}")
1194
+
1195
+ if _localname(element.tag) == "style" and element.text:
1196
+ cleaned = _scrub_style_text(element.text)
1197
+ if cleaned != element.text:
1198
+ element.text = cleaned
1199
+ warnings.append("scrubbed <style> url()/@import")
1200
+
1201
+ scrub(root)
1202
+
1203
+ root.set("viewBox", f"0 0 {SVG_WIDTH} {SVG_HEIGHT}")
1204
+ root.set("width", str(SVG_WIDTH))
1205
+ root.set("height", str(SVG_HEIGHT))
1206
+
1207
+ ET.register_namespace("", SVG_NS)
1208
+ ET.register_namespace("xlink", XLINK_NS)
1209
+ return ET.tostring(root, encoding="unicode"), warnings
1210
+
1211
+
1212
+ def render_local_svg(title: str, seed: int) -> str:
1213
+ """Deterministic retro-landscape SVG for the `local` provider (no network).
1214
+
1215
+ Shares the claude provider's seed → palette/composition scheme so the two
1216
+ stay visually kin per slug."""
1217
+ pal = palette_for(seed)
1218
+ variant = (seed >> 8) % len(COMPOSITION_VARIANTS)
1219
+ rng = seed or 1
1220
+
1221
+ def nxt(bound: int) -> int:
1222
+ nonlocal rng
1223
+ rng = (rng * 1103515245 + 12345) & 0x7FFFFFFF
1224
+ return rng % max(bound, 1)
1225
+
1226
+ w, h = SVG_WIDTH, SVG_HEIGHT
1227
+ parts = [
1228
+ f'<svg xmlns="{SVG_NS}" viewBox="0 0 {w} {h}" width="{w}" height="{h}">',
1229
+ f'<rect width="{w}" height="{h}" fill="{pal[0]}"/>',
1230
+ ]
1231
+ # Sky bands
1232
+ band_h = h // 8
1233
+ for i in range(3):
1234
+ parts.append(
1235
+ f'<rect y="{i * band_h}" width="{w}" height="{band_h}" '
1236
+ f'fill="{pal[1]}" opacity="0.{25 + i * 12}"/>'
1237
+ )
1238
+ # Celestial body with stepped "pixel" rings
1239
+ cx, cy, radius = w - 320 - nxt(400), 240 + nxt(160), 130 + nxt(80)
1240
+ for i, ring_color in enumerate([pal[5], pal[4]]):
1241
+ parts.append(
1242
+ f'<circle cx="{cx}" cy="{cy}" r="{radius - i * 26}" fill="{ring_color}"/>'
1243
+ )
1244
+ # Scanline stripes across the disk (clipped to the circle's chord width)
1245
+ for i in range(3):
1246
+ y = cy + 12 + i * 26
1247
+ dy = abs(y + 5 - cy)
1248
+ if dy >= radius:
1249
+ continue
1250
+ half = int((radius * radius - dy * dy) ** 0.5)
1251
+ parts.append(f'<rect x="{cx - half}" y="{y}" width="{half * 2}" height="10" fill="{pal[0]}"/>')
1252
+ # Layered terrain: three silhouette ranges of blocky steps
1253
+ for layer, color in enumerate([pal[2], pal[3], pal[1]]):
1254
+ base = h - 120 - layer * 170
1255
+ x = -40
1256
+ points = [f"-40,{h}"]
1257
+ while x < w + 80:
1258
+ peak = base - nxt(300) - (2 - layer) * 70
1259
+ width_step = 90 + nxt(150)
1260
+ points.append(f"{x},{peak}")
1261
+ points.append(f"{x + width_step},{peak}")
1262
+ x += width_step
1263
+ points.append(f"{w + 80},{h}")
1264
+ parts.append(f'<polygon points="{" ".join(points)}" fill="{color}"/>')
1265
+ # Variant flourish: grid floor for even variants, stars for odd
1266
+ if variant % 2 == 0:
1267
+ for i in range(1, 7):
1268
+ y = h - i * (i * 8)
1269
+ parts.append(f'<rect y="{y}" width="{w}" height="3" fill="{pal[4]}" opacity="0.35"/>')
1270
+ else:
1271
+ for _ in range(40):
1272
+ sx, sy = nxt(w), nxt(h // 2)
1273
+ size = 3 + nxt(5)
1274
+ parts.append(f'<rect x="{sx}" y="{sy}" width="{size}" height="{size}" fill="{pal[4]}"/>')
1275
+ # CRT scanline veil + vignette bars
1276
+ for y in range(0, h, 8):
1277
+ parts.append(f'<rect y="{y}" width="{w}" height="1" fill="#000" opacity="0.10"/>')
1278
+ parts.append(f'<rect width="{w}" height="26" fill="#000" opacity="0.35"/>')
1279
+ parts.append(f'<rect y="{h - 26}" width="{w}" height="26" fill="#000" opacity="0.35"/>')
1280
+ parts.append("</svg>")
1281
+ return "".join(parts)
1282
+
1283
+
1284
+ # =============================================================================
1285
+ # Rasterizer chain
1286
+ # =============================================================================
1287
+
1288
+ # Tool discovery is per-run stable; memoize the PATH walks so a batch of N
1289
+ # files doesn't pay N x 4 which() scans.
1290
+ @functools.lru_cache(maxsize=None)
1291
+ def _which(tool: str) -> Optional[str]:
1292
+ return shutil.which(tool)
1293
+
1294
+
1295
+ def _run_quiet(cmd: List[str], cwd: Optional[Path] = None, timeout: int = 120) -> bool:
1296
+ try:
1297
+ proc = subprocess.run(
1298
+ cmd, cwd=str(cwd) if cwd else None, capture_output=True, timeout=timeout
1299
+ )
1300
+ except (OSError, subprocess.TimeoutExpired) as exc:
1301
+ debug(f"rasterizer {cmd[0]} failed: {exc}")
1302
+ return False
1303
+ if proc.returncode != 0:
1304
+ debug(f"rasterizer {cmd[0]} exit {proc.returncode}: "
1305
+ f"{proc.stderr.decode('utf-8', 'replace')[:200]}")
1306
+ return False
1307
+ return True
1308
+
1309
+
1310
+ def _playwright_helper(project_root: Path) -> Optional[Path]:
1311
+ candidates = [
1312
+ # Gem layout: helper vendored beside this file.
1313
+ Path(__file__).resolve().parent / "rasterize-svg.js",
1314
+ # Theme/curl layout: <site>/scripts/lib/<engine> + <site>/scripts/dev/.
1315
+ Path(__file__).resolve().parent.parent / "dev" / "rasterize-svg.js",
1316
+ project_root / "scripts" / "dev" / "rasterize-svg.js",
1317
+ ]
1318
+ for candidate in candidates:
1319
+ if candidate.is_file():
1320
+ return candidate
1321
+ return None
1322
+
1323
+
1324
+ def rasterize_svg(
1325
+ svg_path: Path, png_path: Path, project_root: Path, preference: str = "auto"
1326
+ ) -> Optional[str]:
1327
+ """SVG → PNG through the first available tool. Returns the tool name used,
1328
+ or None when nothing worked (caller keeps the .svg)."""
1329
+ w, h = SVG_WIDTH, SVG_HEIGHT
1330
+ helper = _playwright_helper(project_root)
1331
+ chain: List[Tuple[str, Any]] = [
1332
+ ("rsvg", lambda: _which("rsvg-convert") and _run_quiet(
1333
+ ["rsvg-convert", "-w", str(w), "-h", str(h), "-o", str(png_path), str(svg_path)])),
1334
+ ("inkscape", lambda: _which("inkscape") and _run_quiet(
1335
+ ["inkscape", str(svg_path), "--export-type=png",
1336
+ f"--export-filename={png_path}", "-w", str(w), "-h", str(h)])),
1337
+ ("magick", lambda: (
1338
+ (_which("magick") and _run_quiet(
1339
+ ["magick", str(svg_path), "-resize", f"{w}x{h}", str(png_path)]))
1340
+ or (_which("convert") and _run_quiet(
1341
+ ["convert", str(svg_path), "-resize", f"{w}x{h}", str(png_path)]))
1342
+ )),
1343
+ ("playwright", lambda: helper is not None and _which("node") and _run_quiet(
1344
+ ["node", str(helper), str(svg_path), str(png_path), str(w), str(h)],
1345
+ cwd=project_root, timeout=180)),
1346
+ ]
1347
+ if preference == "none":
1348
+ return None
1349
+ if preference != "auto":
1350
+ chain = [entry for entry in chain if entry[0] == preference]
1351
+ if not chain:
1352
+ warn(f"Unknown rasterizer '{preference}'")
1353
+ return None
1354
+ for name, attempt in chain:
1355
+ if attempt():
1356
+ try:
1357
+ if png_path.is_file() and png_path.read_bytes()[:8] == PNG_SIGNATURE:
1358
+ debug(f"Rasterized with {name}: {png_path.name}")
1359
+ return name
1360
+ except OSError:
1361
+ pass
1362
+ png_path.unlink(missing_ok=True)
1363
+ return None
1364
+
1365
+
1366
+ # =============================================================================
1367
+ # Anthropic client (Claude Code OAuth → API key → claude CLI)
1368
+ # =============================================================================
1369
+
1370
+ class ClaudeRefusal(Exception):
1371
+ def __init__(self, category: Optional[str]):
1372
+ self.category = category
1373
+ super().__init__(f"request declined (category: {category or 'unspecified'})")
1374
+
1375
+
1376
+ class ClaudeTruncated(Exception):
1377
+ pass
1378
+
1379
+
1380
+ class AnthropicClient:
1381
+ """Minimal Messages-API client honoring the repo's credential conventions.
1382
+
1383
+ Modes (first match wins — mirrors templates/deploy/chat-proxy/worker.js):
1384
+ oauth CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_AUTH_TOKEN → Bearer +
1385
+ `anthropic-beta: oauth-2025-04-20` + Claude Code first system block
1386
+ api_key ANTHROPIC_API_KEY → x-api-key header
1387
+ cli `claude -p` headless (rides the local Claude Code login)
1388
+ """
1389
+
1390
+ def __init__(self, env: Optional[Dict[str, str]] = None):
1391
+ self.env = dict(env if env is not None else os.environ)
1392
+ self.mode: Optional[str] = None
1393
+ self.token: Optional[str] = None
1394
+ if self.env.get("CLAUDE_CODE_OAUTH_TOKEN"):
1395
+ self.mode, self.token = "oauth", self.env["CLAUDE_CODE_OAUTH_TOKEN"]
1396
+ elif self.env.get("ANTHROPIC_AUTH_TOKEN"):
1397
+ self.mode, self.token = "oauth", self.env["ANTHROPIC_AUTH_TOKEN"]
1398
+ elif self.env.get("ANTHROPIC_API_KEY"):
1399
+ self.mode, self.token = "api_key", self.env["ANTHROPIC_API_KEY"]
1400
+ elif shutil.which("claude"):
1401
+ self.mode = "cli"
1402
+
1403
+ def available(self) -> bool:
1404
+ return self.mode is not None
1405
+
1406
+ def describe(self) -> str:
1407
+ return {
1408
+ "oauth": "Claude Code OAuth token (Bearer)",
1409
+ "api_key": "Anthropic API key",
1410
+ "cli": "claude CLI (local Claude Code login)",
1411
+ None: "none",
1412
+ }[self.mode]
1413
+
1414
+ def headers(self) -> Dict[str, str]:
1415
+ if self.mode == "oauth":
1416
+ return {
1417
+ "Authorization": f"Bearer {self.token}",
1418
+ "anthropic-version": ANTHROPIC_VERSION,
1419
+ "anthropic-beta": OAUTH_BETA,
1420
+ }
1421
+ if self.mode == "api_key":
1422
+ return {
1423
+ "x-api-key": self.token or "",
1424
+ "anthropic-version": ANTHROPIC_VERSION,
1425
+ }
1426
+ raise RuntimeError(f"no API headers for mode {self.mode}")
1427
+
1428
+ def complete(
1429
+ self,
1430
+ system_text: str,
1431
+ user_text: str,
1432
+ model: str = DEFAULT_CLAUDE_MODEL,
1433
+ max_tokens: int = CLAUDE_MAX_TOKENS,
1434
+ ) -> str:
1435
+ """One Messages-API turn (or CLI run); returns concatenated text blocks.
1436
+
1437
+ Raises ClaudeRefusal / ClaudeTruncated / HttpStatusError / RuntimeError.
1438
+ """
1439
+ if self.mode == "cli":
1440
+ return self._complete_cli(system_text, user_text, model)
1441
+ if self.mode is None:
1442
+ raise RuntimeError("no Anthropic credential configured")
1443
+
1444
+ # The Claude Code identity block is REQUIRED as the first system block
1445
+ # for OAuth tokens (worker.js recipe) and intentionally omitted for
1446
+ # plain API keys, matching the chat proxy's per-mode behavior.
1447
+ system_blocks = [{"type": "text", "text": system_text}]
1448
+ if self.mode == "oauth":
1449
+ system_blocks.insert(0, {"type": "text", "text": CLAUDE_CODE_SYSTEM_PROMPT})
1450
+
1451
+ payload: Dict[str, Any] = {
1452
+ "model": model,
1453
+ "max_tokens": max_tokens,
1454
+ "thinking": {"type": "adaptive"},
1455
+ "system": system_blocks,
1456
+ "messages": [{"role": "user", "content": user_text}],
1457
+ }
1458
+
1459
+ def call(body: Dict[str, Any]) -> dict:
1460
+ return with_retries(
1461
+ lambda: http_json(ANTHROPIC_API_URL, body, self.headers(), timeout=900),
1462
+ "Anthropic API",
1463
+ )
1464
+
1465
+ try:
1466
+ data = call(payload)
1467
+ except HttpStatusError as exc:
1468
+ if exc.status == 400 and "thinking" in exc.message().lower():
1469
+ debug("Retrying without thinking parameter")
1470
+ payload.pop("thinking", None)
1471
+ data = call(payload)
1472
+ elif exc.status == 401 and self.mode == "oauth":
1473
+ raise RuntimeError(
1474
+ "Anthropic rejected the OAuth token (401). It may be expired "
1475
+ "— mint a fresh one with `claude setup-token`, or use "
1476
+ "ANTHROPIC_API_KEY instead."
1477
+ ) from None
1478
+ else:
1479
+ raise
1480
+
1481
+ stop_reason = data.get("stop_reason")
1482
+ if stop_reason == "refusal":
1483
+ details = data.get("stop_details") or {}
1484
+ raise ClaudeRefusal(details.get("category") if isinstance(details, dict) else None)
1485
+
1486
+ text = "".join(
1487
+ block.get("text", "")
1488
+ for block in data.get("content", [])
1489
+ if isinstance(block, dict) and block.get("type") == "text"
1490
+ )
1491
+ if stop_reason == "max_tokens":
1492
+ raise ClaudeTruncated(text)
1493
+ return text
1494
+
1495
+ def complete_vision(
1496
+ self,
1497
+ system_text: str,
1498
+ user_text: str,
1499
+ image_path: Path,
1500
+ model: str = DEFAULT_CLAUDE_MODEL,
1501
+ max_tokens: int = 2048,
1502
+ ) -> str:
1503
+ """One vision turn over a local PNG (review stage). CLI mode passes the
1504
+ file path and lets `claude -p` read it; API modes embed base64."""
1505
+ if self.mode == "cli":
1506
+ prompt = (
1507
+ f"{system_text}\n\n---\n\nFirst use the Read tool to view the "
1508
+ f"image file at {image_path.resolve()} — then respond.\n\n{user_text}"
1509
+ )
1510
+ return self._run_cli(prompt, model, allowed_tools="Read")
1511
+ if self.mode is None:
1512
+ raise RuntimeError("no Anthropic credential configured")
1513
+
1514
+ system_blocks = [{"type": "text", "text": system_text}]
1515
+ if self.mode == "oauth":
1516
+ system_blocks.insert(0, {"type": "text", "text": CLAUDE_CODE_SYSTEM_PROMPT})
1517
+ payload: Dict[str, Any] = {
1518
+ "model": model,
1519
+ "max_tokens": max_tokens,
1520
+ "thinking": {"type": "adaptive"},
1521
+ "system": system_blocks,
1522
+ "messages": [{
1523
+ "role": "user",
1524
+ "content": [
1525
+ {"type": "image", "source": {
1526
+ "type": "base64", "media_type": "image/png",
1527
+ "data": base64.b64encode(image_path.read_bytes()).decode("ascii"),
1528
+ }},
1529
+ {"type": "text", "text": user_text},
1530
+ ],
1531
+ }],
1532
+ }
1533
+ data = with_retries(
1534
+ lambda: http_json(ANTHROPIC_API_URL, payload, self.headers(), timeout=900),
1535
+ "Anthropic API (review)",
1536
+ )
1537
+ if data.get("stop_reason") == "refusal":
1538
+ details = data.get("stop_details") or {}
1539
+ raise ClaudeRefusal(details.get("category") if isinstance(details, dict) else None)
1540
+ return "".join(
1541
+ block.get("text", "")
1542
+ for block in data.get("content", [])
1543
+ if isinstance(block, dict) and block.get("type") == "text"
1544
+ )
1545
+
1546
+ def _complete_cli(self, system_text: str, user_text: str, model: str) -> str:
1547
+ return self._run_cli(f"{system_text}\n\n---\n\n{user_text}", model)
1548
+
1549
+ def _run_cli(self, prompt: str, model: str,
1550
+ allowed_tools: Optional[str] = None) -> str:
1551
+ cmd = ["claude", "-p", "--model", model, "--output-format", "text"]
1552
+ if allowed_tools:
1553
+ cmd += ["--allowedTools", allowed_tools]
1554
+ try:
1555
+ proc = subprocess.run(
1556
+ cmd,
1557
+ input=prompt,
1558
+ capture_output=True,
1559
+ text=True,
1560
+ timeout=300,
1561
+ )
1562
+ except (OSError, subprocess.TimeoutExpired) as exc:
1563
+ raise RuntimeError(f"claude CLI failed: {exc}") from None
1564
+ if proc.returncode != 0 or not proc.stdout.strip():
1565
+ tail = (proc.stderr or "").strip()[-300:]
1566
+ raise RuntimeError(
1567
+ f"claude CLI exited {proc.returncode}: {tail or 'no output'}"
1568
+ )
1569
+ return proc.stdout
1570
+
1571
+
1572
+ CLAUDE_CREDENTIAL_HINT = (
1573
+ "Claude orchestration (article analysis + image review) needs any ONE of:\n"
1574
+ " 1. CLAUDE_CODE_OAUTH_TOKEN — run `claude setup-token` (Claude Pro/Max)\n"
1575
+ " 2. ANTHROPIC_AUTH_TOKEN — short-lived Bearer (e.g. `ant auth print-credentials`)\n"
1576
+ " 3. ANTHROPIC_API_KEY — key from console.anthropic.com\n"
1577
+ " 4. the `claude` CLI installed and logged in (used automatically)\n"
1578
+ "Falling back to the template prompt and skipping review (or pass "
1579
+ "--prompt-engine template --review none to silence this)."
1580
+ )
1581
+
1582
+
1583
+ # =============================================================================
1584
+ # Providers
1585
+ # =============================================================================
1586
+
1587
+ @dataclass
1588
+ class ImageResult:
1589
+ ok: bool
1590
+ kind: str = "png" # png | svg
1591
+ path: Optional[Path] = None
1592
+ error: Optional[str] = None
1593
+
1594
+
1595
+ class EditUnsupported(Exception):
1596
+ def __init__(self, provider: str):
1597
+ super().__init__(f"provider '{provider}' has no image-edit capability")
1598
+ self.provider = provider
1599
+
1600
+
1601
+ class Provider:
1602
+ name = "base"
1603
+
1604
+ def is_configured(self, env: Dict[str, str]) -> bool:
1605
+ raise NotImplementedError
1606
+
1607
+ def missing_hint(self, env: Dict[str, str]) -> str:
1608
+ raise NotImplementedError
1609
+
1610
+ def default_model(self) -> str:
1611
+ raise NotImplementedError
1612
+
1613
+ def generate(self, prompt: str, settings: Settings, out_base: Path,
1614
+ ctx: "RunContext") -> ImageResult:
1615
+ raise NotImplementedError
1616
+
1617
+ def edit(self, image_path: Path, prompt: str, settings: Settings,
1618
+ ctx: "RunContext", out_path: Optional[Path] = None) -> ImageResult:
1619
+ """Enhance an existing image. Providers without an edit capability
1620
+ raise EditUnsupported; the runner then falls back to OpenAI (the
1621
+ historical behavior of --enhance)."""
1622
+ raise EditUnsupported(self.name)
1623
+
1624
+
1625
+ @dataclass
1626
+ class RunContext:
1627
+ """Run-scoped services handed to providers."""
1628
+ project_root: Path
1629
+ env: Dict[str, str]
1630
+ anthropic: Optional[AnthropicClient] = None
1631
+ slug: str = ""
1632
+
1633
+ def claude(self) -> AnthropicClient:
1634
+ if self.anthropic is None:
1635
+ self.anthropic = AnthropicClient(self.env)
1636
+ return self.anthropic
1637
+
1638
+
1639
+ def adapt_openai_size_quality(model: str, size: str, quality: str) -> Tuple[str, str]:
1640
+ """Historical engine behavior: adapt shared settings per model family."""
1641
+ if model.startswith("gpt-image-") and size == "1792x1024":
1642
+ size = "1536x1024"
1643
+ elif model.startswith("dall-e-") and quality == "auto":
1644
+ quality = "standard"
1645
+ return size, quality
1646
+
1647
+
1648
+ def _write_image_payload(entry: Dict[str, Any], out_path: Path) -> bool:
1649
+ """Persist one OpenAI-style data[0] entry (b64_json preferred, else url)."""
1650
+ b64 = entry.get("b64_json")
1651
+ if b64:
1652
+ out_path.write_bytes(base64.b64decode(b64))
1653
+ return True
1654
+ url = entry.get("url")
1655
+ if url:
1656
+ download_to(url, out_path)
1657
+ return True
1658
+ return False
1659
+
1660
+
1661
+ class OpenAIProvider(Provider):
1662
+ name = "openai"
1663
+
1664
+ def is_configured(self, env: Dict[str, str]) -> bool:
1665
+ return bool(env.get("OPENAI_API_KEY"))
1666
+
1667
+ def missing_hint(self, env: Dict[str, str]) -> str:
1668
+ return "OPENAI_API_KEY environment variable is required for the OpenAI provider"
1669
+
1670
+ def default_model(self) -> str:
1671
+ return "gpt-image-2"
1672
+
1673
+ def _headers(self, env: Dict[str, str]) -> Dict[str, str]:
1674
+ return {"Authorization": f"Bearer {env['OPENAI_API_KEY']}"}
1675
+
1676
+ def generate(self, prompt, settings, out_base, ctx) -> ImageResult:
1677
+ model = effective_model(settings, self)
1678
+ size, quality = adapt_openai_size_quality(model, settings.size, settings.quality)
1679
+ out_path = out_base.with_suffix(".png")
1680
+ payload = {"model": model, "prompt": prompt, "n": 1, "size": size, "quality": quality}
1681
+ debug(f"OpenAI generate: model={model} size={size} quality={quality}")
1682
+ try:
1683
+ data = with_retries(
1684
+ lambda: http_json(
1685
+ "https://api.openai.com/v1/images/generations",
1686
+ payload, self._headers(ctx.env), timeout=900,
1687
+ ),
1688
+ "OpenAI API",
1689
+ )
1690
+ entries = data.get("data") or []
1691
+ if not entries or not _write_image_payload(entries[0], out_path):
1692
+ return ImageResult(False, error="No image data in OpenAI response")
1693
+ return ImageResult(True, "png", out_path)
1694
+ except HttpStatusError as exc:
1695
+ return ImageResult(False, error=f"OpenAI API error: {exc.message()}")
1696
+ except Exception as exc:
1697
+ return ImageResult(False, error=str(exc))
1698
+
1699
+ def edit(self, image_path, prompt, settings, ctx, out_path=None) -> ImageResult:
1700
+ model = settings.enhance_model
1701
+ fields = {
1702
+ "prompt": prompt,
1703
+ "model": model,
1704
+ "n": "1",
1705
+ "size": "auto",
1706
+ "quality": settings.enhance_quality,
1707
+ "output_format": settings.enhance_format,
1708
+ }
1709
+ # gpt-image-2 does not accept input_fidelity (historical behavior).
1710
+ if model != "gpt-image-2":
1711
+ fields["input_fidelity"] = settings.enhance_fidelity
1712
+ files = [("image[]", image_path.name, image_path.read_bytes(), "image/png")]
1713
+ out_path = out_path or image_path
1714
+ debug(f"OpenAI edit: model={model} fidelity="
1715
+ f"{fields.get('input_fidelity', '(omitted)')} format={settings.enhance_format}")
1716
+ try:
1717
+ data = with_retries(
1718
+ lambda: http_multipart(
1719
+ "https://api.openai.com/v1/images/edits",
1720
+ fields, files, self._headers(ctx.env), timeout=900,
1721
+ ),
1722
+ "OpenAI edits API",
1723
+ )
1724
+ usage = data.get("usage") or {}
1725
+ if usage.get("total_tokens"):
1726
+ debug(f"Token usage: {usage.get('total_tokens')} total")
1727
+ entries = data.get("data") or []
1728
+ if not entries or not _write_image_payload(entries[0], out_path):
1729
+ return ImageResult(False, error="No image data in enhance response")
1730
+ revised = entries[0].get("revised_prompt")
1731
+ if revised:
1732
+ debug(f"Revised prompt: {revised[:200]}...")
1733
+ return ImageResult(True, "png", out_path)
1734
+ except HttpStatusError as exc:
1735
+ return ImageResult(False, error=f"OpenAI enhance API error: {exc.message()}")
1736
+ except Exception as exc:
1737
+ return ImageResult(False, error=str(exc))
1738
+
1739
+
1740
+ class XAIProvider(Provider):
1741
+ name = "xai"
1742
+
1743
+ def is_configured(self, env: Dict[str, str]) -> bool:
1744
+ return bool(env.get("XAI_API_KEY"))
1745
+
1746
+ def missing_hint(self, env: Dict[str, str]) -> str:
1747
+ return "XAI_API_KEY environment variable is required for the xAI provider"
1748
+
1749
+ def default_model(self) -> str:
1750
+ return "grok-2-image"
1751
+
1752
+ def generate(self, prompt, settings, out_base, ctx) -> ImageResult:
1753
+ model = effective_model(settings, self)
1754
+ out_path = out_base.with_suffix(".png")
1755
+ payload = {"model": model, "prompt": prompt[:1000], "n": 1} # 1024-char cap
1756
+ try:
1757
+ data = with_retries(
1758
+ lambda: http_json(
1759
+ "https://api.x.ai/v1/images/generations",
1760
+ payload,
1761
+ {"Authorization": f"Bearer {ctx.env['XAI_API_KEY']}"},
1762
+ timeout=900,
1763
+ ),
1764
+ "xAI API",
1765
+ )
1766
+ entries = data.get("data") or []
1767
+ if not entries or not _write_image_payload(entries[0], out_path):
1768
+ return ImageResult(False, error="No image data in xAI response")
1769
+ return ImageResult(True, "png", out_path)
1770
+ except HttpStatusError as exc:
1771
+ return ImageResult(False, error=f"xAI API error: {exc.message()}")
1772
+ except Exception as exc:
1773
+ return ImageResult(False, error=str(exc))
1774
+
1775
+
1776
+ class StabilityProvider(Provider):
1777
+ name = "stability"
1778
+
1779
+ def is_configured(self, env: Dict[str, str]) -> bool:
1780
+ return bool(env.get("STABILITY_API_KEY"))
1781
+
1782
+ def missing_hint(self, env: Dict[str, str]) -> str:
1783
+ return "STABILITY_API_KEY environment variable is required for the Stability AI provider"
1784
+
1785
+ def default_model(self) -> str:
1786
+ return "stable-diffusion-xl-1024-v1-0"
1787
+
1788
+ def generate(self, prompt, settings, out_base, ctx) -> ImageResult:
1789
+ out_path = out_base.with_suffix(".png")
1790
+ payload = {
1791
+ "text_prompts": [{"text": prompt}],
1792
+ "cfg_scale": 7,
1793
+ # SDXL v1 endpoint accepts fixed dimension sets; 1024x1024 preserved
1794
+ # from the original engine.
1795
+ "height": 1024,
1796
+ "width": 1024,
1797
+ "samples": 1,
1798
+ "steps": 30,
1799
+ }
1800
+ try:
1801
+ data = with_retries(
1802
+ lambda: http_json(
1803
+ "https://api.stability.ai/v1/generation/"
1804
+ "stable-diffusion-xl-1024-v1-0/text-to-image",
1805
+ payload,
1806
+ {"Authorization": f"Bearer {ctx.env['STABILITY_API_KEY']}"},
1807
+ timeout=900,
1808
+ ),
1809
+ "Stability API",
1810
+ )
1811
+ artifacts = data.get("artifacts") or []
1812
+ if not artifacts or not artifacts[0].get("base64"):
1813
+ return ImageResult(False, error="No image data in Stability response")
1814
+ out_path.write_bytes(base64.b64decode(artifacts[0]["base64"]))
1815
+ return ImageResult(True, "png", out_path)
1816
+ except HttpStatusError as exc:
1817
+ return ImageResult(False, error=f"Stability API error: {exc.message()}")
1818
+ except Exception as exc:
1819
+ return ImageResult(False, error=str(exc))
1820
+
1821
+
1822
+ class GeminiProvider(Provider):
1823
+ name = "gemini"
1824
+
1825
+ def is_configured(self, env: Dict[str, str]) -> bool:
1826
+ return bool(env.get("GEMINI_API_KEY"))
1827
+
1828
+ def missing_hint(self, env: Dict[str, str]) -> str:
1829
+ return "GEMINI_API_KEY environment variable is required for the Gemini provider"
1830
+
1831
+ def default_model(self) -> str:
1832
+ return "gemini-2.5-flash-image"
1833
+
1834
+ def generate(self, prompt, settings, out_base, ctx) -> ImageResult:
1835
+ model = effective_model(settings, self)
1836
+ out_path = out_base.with_suffix(".png")
1837
+ url = (
1838
+ "https://generativelanguage.googleapis.com/v1beta/models/"
1839
+ f"{model}:generateContent"
1840
+ )
1841
+ payload = {"contents": [{"parts": [{"text": prompt}]}]}
1842
+ try:
1843
+ data = with_retries(
1844
+ lambda: http_json(
1845
+ url, payload,
1846
+ {"x-goog-api-key": ctx.env["GEMINI_API_KEY"]},
1847
+ timeout=900,
1848
+ ),
1849
+ "Gemini API",
1850
+ )
1851
+ for candidate in data.get("candidates") or []:
1852
+ for part in ((candidate.get("content") or {}).get("parts")) or []:
1853
+ inline = part.get("inlineData") or part.get("inline_data")
1854
+ if inline and inline.get("data"):
1855
+ out_path.write_bytes(base64.b64decode(inline["data"]))
1856
+ return ImageResult(True, "png", out_path)
1857
+ return ImageResult(False, error="No inline image data in Gemini response")
1858
+ except HttpStatusError as exc:
1859
+ return ImageResult(False, error=f"Gemini API error: {exc.message()}")
1860
+ except Exception as exc:
1861
+ return ImageResult(False, error=str(exc))
1862
+
1863
+
1864
+ class SvgProviderMixin:
1865
+ """Shared sanitize → write → rasterize tail for SVG-producing providers."""
1866
+
1867
+ def finish_svg(self, svg_text: str, out_base: Path, settings: Settings,
1868
+ ctx: RunContext) -> ImageResult:
1869
+ try:
1870
+ clean, notes = sanitize_svg(svg_text)
1871
+ except SvgError as exc:
1872
+ return ImageResult(False, error=str(exc))
1873
+ for note in notes:
1874
+ warn(f"SVG sanitizer: {note}")
1875
+ svg_path = out_base.with_suffix(".svg")
1876
+ png_path = out_base.with_suffix(".png")
1877
+ svg_path.write_text(clean, encoding="utf-8")
1878
+ tool = rasterize_svg(svg_path, png_path, ctx.project_root, settings.rasterizer)
1879
+ if tool:
1880
+ svg_path.unlink(missing_ok=True)
1881
+ return ImageResult(True, "png", png_path)
1882
+ warn(
1883
+ "No SVG rasterizer available — keeping the .svg preview. Social "
1884
+ "og:image works best as PNG: install librsvg (`brew install librsvg`) "
1885
+ "or Playwright (`npx playwright install chromium`)."
1886
+ )
1887
+ return ImageResult(True, "svg", svg_path)
1888
+
1889
+
1890
+ class LocalProvider(Provider, SvgProviderMixin):
1891
+ name = "local"
1892
+
1893
+ def is_configured(self, env: Dict[str, str]) -> bool:
1894
+ return True
1895
+
1896
+ def missing_hint(self, env: Dict[str, str]) -> str:
1897
+ return ""
1898
+
1899
+ def default_model(self) -> str:
1900
+ return "template-svg"
1901
+
1902
+ def generate(self, prompt, settings, out_base, ctx) -> ImageResult:
1903
+ seed = seed_for(ctx.slug or out_base.stem)
1904
+ svg_text = render_local_svg(ctx.slug, seed)
1905
+ return self.finish_svg(svg_text, out_base, settings, ctx)
1906
+
1907
+ def edit(self, image_path, prompt, settings, ctx, out_path=None) -> ImageResult:
1908
+ # Historical behavior: the local provider "enhances" by doing nothing
1909
+ # (no API), so dry testing of --enhance needs no credentials.
1910
+ warn("Local provider: No actual enhancement. Logging prompt...")
1911
+ debug(f"Enhancement prompt: {prompt[:400]}...")
1912
+ info(f"Placeholder: would enhance {image_path}")
1913
+ return ImageResult(True, "png", image_path)
1914
+
1915
+
1916
+ # Renderers only — Claude is the orchestration layer (claude_article_brief /
1917
+ # claude_review_image) that sits in front of ANY of these, not a provider.
1918
+ PROVIDERS: Dict[str, Provider] = {
1919
+ provider.name: provider
1920
+ for provider in (
1921
+ OpenAIProvider(),
1922
+ XAIProvider(),
1923
+ StabilityProvider(),
1924
+ GeminiProvider(),
1925
+ LocalProvider(),
1926
+ )
1927
+ }
1928
+
1929
+
1930
+ # =============================================================================
1931
+ # Orchestrator
1932
+ # =============================================================================
1933
+
1934
+ @dataclass
1935
+ class Stats:
1936
+ processed: int = 0
1937
+ generated: int = 0
1938
+ enhanced: int = 0
1939
+ skipped: int = 0
1940
+ errors: int = 0
1941
+ _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
1942
+
1943
+ def inc(self, name: str) -> None:
1944
+ with self._lock:
1945
+ setattr(self, name, getattr(self, name) + 1)
1946
+
1947
+
1948
+ class Runner:
1949
+ def __init__(self, settings: Settings, project_root: Path,
1950
+ ctx: Optional[RunContext] = None):
1951
+ self.settings = settings
1952
+ self.root = project_root
1953
+ self.stats = Stats()
1954
+ self.authors = read_authors(project_root, settings.authors_file)
1955
+ self.ctx = ctx or RunContext(project_root=project_root, env=dict(os.environ))
1956
+
1957
+ # -- discovery ------------------------------------------------------------
1958
+
1959
+ def collection_path(self, name: str) -> Path:
1960
+ """`<root>/<collections_dir>/_<name>` — collections_dir is Jekyll's own
1961
+ key (default "" = site root; zer0-mistakes sets `pages`)."""
1962
+ base = self.root
1963
+ for part in self.settings.collections_dir.split("/"):
1964
+ if part:
1965
+ base = base / part
1966
+ return base / f"_{name}"
1967
+
1968
+ def discover(self, collection_path: Path) -> List[Path]:
1969
+ if not collection_path.is_dir():
1970
+ warn(f"Collection directory not found: {collection_path}")
1971
+ return []
1972
+ return sorted(collection_path.rglob("*.md"))
1973
+
1974
+ # -- per-file processing --------------------------------------------------
1975
+
1976
+ def process_file(self, path: Path) -> None:
1977
+ if _interrupted:
1978
+ return
1979
+ settings = self.settings
1980
+ self.stats.inc("processed")
1981
+ debug(f"Processing file: {path}")
1982
+
1983
+ cf = parse_front_matter(path, settings.front_matter_key)
1984
+ if cf is None:
1985
+ self.stats.inc("skipped")
1986
+ return
1987
+
1988
+ if settings.enhance:
1989
+ self._enhance_file(cf)
1990
+ return
1991
+
1992
+ # Generate mode -------------------------------------------------------
1993
+ if cf.preview and check_preview_exists(cf.preview, settings, self.root):
1994
+ if not settings.force:
1995
+ debug(f"Preview already exists and is valid: {cf.preview}")
1996
+ self.stats.inc("skipped")
1997
+ return
1998
+ info(f"Force mode: regenerating preview for {cf.title}")
1999
+
2000
+ if settings.list_only:
2001
+ print(f"{Colors.YELLOW}Missing preview:{Colors.NC} {path}")
2002
+ print(f" Title: {cf.title}")
2003
+ if cf.preview:
2004
+ print(f" Current preview (not found): {cf.preview}")
2005
+ print()
2006
+ return
2007
+
2008
+ info(f"Generating preview for: {cf.title}")
2009
+
2010
+ slug = generate_filename(cf.title)
2011
+ if not slug:
2012
+ warn(f"Cannot derive filename from title in {path}")
2013
+ self.stats.inc("errors")
2014
+ return
2015
+ out_base = self.root / settings.output_dir / slug
2016
+
2017
+ # Author overrides apply only once an image/prompt is actually built.
2018
+ file_settings = apply_author_overrides(
2019
+ settings, author_preview_overrides(self.authors, cf.author)
2020
+ )
2021
+ if file_settings is not settings:
2022
+ info(f" ↳ Author '{cf.author}' preview overrides applied "
2023
+ f"({settings.authors_file})")
2024
+
2025
+ # ---- Analyze: Claude reads the article and writes the art brief ----
2026
+ base_prompt = build_prompt(cf, file_settings)
2027
+ prompt = base_prompt
2028
+ orchestrated = settings.provider != "local" # local is deterministic
2029
+ if (orchestrated and file_settings.prompt_engine == "claude"
2030
+ and not settings.dry_run):
2031
+ prompt = claude_article_brief(
2032
+ self.ctx.claude(), cf, file_settings, base_prompt)
2033
+ debug(f"Generated prompt: {prompt[:500]}...")
2034
+
2035
+ if settings.dry_run:
2036
+ info("[DRY RUN] Would generate image:")
2037
+ print(f" Provider: {settings.provider}")
2038
+ if orchestrated and file_settings.prompt_engine == "claude":
2039
+ print(" Prompt engine: claude (article analysis runs at generation time)")
2040
+ if orchestrated and file_settings.review_engine == "claude":
2041
+ print(" Review: claude (image review runs at generation time)")
2042
+ print(f" Output: {out_base.with_suffix('.png')}")
2043
+ print(f" Preview path: {preview_front_matter_path(file_settings, slug + '.png')}")
2044
+ print(f" Prompt: {prompt[:400]}...")
2045
+ print()
2046
+ self.stats.inc("generated")
2047
+ return
2048
+
2049
+ provider = PROVIDERS[settings.provider]
2050
+ # Per-file context copy: workers must not share a mutable slug (the
2051
+ # local provider derives its deterministic seed from it). The copy
2052
+ # carries the shared AnthropicClient reference, which is stateless
2053
+ # after init and therefore thread-safe.
2054
+ file_ctx = replace(self.ctx, slug=slug)
2055
+ # ---- Produce: the selected raster model renders the brief ----
2056
+ result = provider.generate(prompt, file_settings, out_base, file_ctx)
2057
+
2058
+ # ---- Review: Claude inspects the render; at most ONE regeneration ----
2059
+ if (orchestrated and file_settings.review_engine == "claude"
2060
+ and result.ok and result.path and result.kind == "png"):
2061
+ approved, critique, revised = claude_review_image(
2062
+ self.ctx.claude(), result.path, cf, prompt, file_settings)
2063
+ if approved:
2064
+ if critique:
2065
+ debug(f"Claude review: {critique}")
2066
+ else:
2067
+ info(f" ↳ Claude review requested a revision: {critique}")
2068
+ debug(f"Revised prompt: {revised[:300]}...")
2069
+ retry = provider.generate(revised, file_settings, out_base, file_ctx)
2070
+ if retry.ok and retry.path:
2071
+ result = retry
2072
+ success(" ↳ Regenerated with Claude's revised brief")
2073
+ else:
2074
+ warn(f" ↳ Revision render failed "
2075
+ f"({retry.error or 'unknown'}); keeping the first image")
2076
+
2077
+ if result.ok and result.path:
2078
+ fm_value = preview_front_matter_path(file_settings, result.path.name)
2079
+ if update_front_matter(path, fm_value, dry_run=False,
2080
+ key=settings.front_matter_key):
2081
+ self.stats.inc("generated")
2082
+ # Serial-mode pacing between paid API calls (historical
2083
+ # behavior). In parallel mode a per-worker sleep throttles
2084
+ # nothing — it only burns worker capacity — so skip it.
2085
+ if POST_GENERATION_SLEEP and settings.parallel <= 1:
2086
+ time.sleep(POST_GENERATION_SLEEP)
2087
+ else:
2088
+ self.stats.inc("errors")
2089
+ else:
2090
+ warn(f"Failed to generate image for: {cf.title}")
2091
+ if result.error:
2092
+ warn(f" {result.error}")
2093
+ self.stats.inc("errors")
2094
+
2095
+ def _enhance_file(self, cf: ContentFile) -> None:
2096
+ settings = self.settings
2097
+ existing = find_preview_image(cf.preview, settings, self.root)
2098
+ if existing is None:
2099
+ warn(f"No existing preview image found for: {cf.title}")
2100
+ warn(f" Expected at: {cf.preview}")
2101
+ warn(" Use without --enhance to generate a new image first.")
2102
+ self.stats.inc("skipped")
2103
+ return
2104
+
2105
+ info(f"Enhancing preview for: {cf.title}")
2106
+ file_settings = apply_author_overrides(
2107
+ settings, author_preview_overrides(self.authors, cf.author)
2108
+ )
2109
+ prompt = build_enhance_prompt(cf, file_settings)
2110
+ debug(f"Enhancement prompt: {prompt[:400]}...")
2111
+
2112
+ if settings.dry_run:
2113
+ info("[DRY RUN] Would enhance image:")
2114
+ print(f" Source: {existing}")
2115
+ print(f" Model: {settings.enhance_model}")
2116
+ print(f" Prompt: {prompt[:400]}...")
2117
+ print()
2118
+ self.stats.inc("enhanced")
2119
+ return
2120
+
2121
+ # --enhance-format other than the current extension writes a NEW file
2122
+ # (content and extension must agree) and repoints the front matter;
2123
+ # the default png-onto-png flow enhances in place with a backup.
2124
+ out_path = existing.with_suffix("." + settings.enhance_format)
2125
+ in_place = out_path == existing
2126
+
2127
+ if in_place:
2128
+ backup = existing.with_name(existing.stem + "_pre-enhance" + existing.suffix)
2129
+ if not backup.exists():
2130
+ shutil.copy2(existing, backup)
2131
+ info(f"Original backed up to: {backup.name}")
2132
+ else:
2133
+ debug(f"Backup already exists: {backup}")
2134
+ else:
2135
+ backup = existing # original file is untouched and acts as the fallback
2136
+
2137
+ # Capability-driven routing: the active provider's edit() runs when it
2138
+ # has one; EditUnsupported falls back to OpenAI (historical behavior).
2139
+ provider = PROVIDERS[settings.provider]
2140
+ try:
2141
+ result = provider.edit(existing, prompt, file_settings, self.ctx, out_path)
2142
+ except EditUnsupported:
2143
+ warn(f"Enhancement not supported for provider: {settings.provider} "
2144
+ "(falling back to OpenAI)")
2145
+ openai_provider = PROVIDERS["openai"]
2146
+ if not openai_provider.is_configured(self.ctx.env):
2147
+ warn("Enhance requires OPENAI_API_KEY (the images/edits API is OpenAI-only).")
2148
+ self.stats.inc("errors")
2149
+ return
2150
+ result = openai_provider.edit(existing, prompt, file_settings, self.ctx, out_path)
2151
+
2152
+ if result.ok:
2153
+ if not in_place and result.path and result.path != existing:
2154
+ old_value = cf.preview or ""
2155
+ new_value = (
2156
+ old_value.rsplit("/", 1)[0] + "/" + result.path.name
2157
+ if "/" in old_value else result.path.name
2158
+ )
2159
+ update_front_matter(cf.path, new_value, dry_run=False,
2160
+ key=self.settings.front_matter_key)
2161
+ info(f"Preview extension changed: {existing.name} → {result.path.name}")
2162
+ success(f"Enhanced image saved to: {result.path or existing}")
2163
+ self.stats.inc("enhanced")
2164
+ else:
2165
+ warn(f"Failed to enhance image for: {cf.title}")
2166
+ if result.error:
2167
+ warn(f" {result.error}")
2168
+ info(f"Original preserved at: {backup}")
2169
+ self.stats.inc("errors")
2170
+
2171
+ # -- collection / run loop ------------------------------------------------
2172
+
2173
+ def process_collection(self, collection_path: Path) -> None:
2174
+ files = self.discover(collection_path)
2175
+ if self.settings.batch > 0:
2176
+ files = files[: self.settings.batch]
2177
+ if not files:
2178
+ return
2179
+ serial = (
2180
+ self.settings.list_only
2181
+ or self.settings.dry_run
2182
+ or self.settings.parallel <= 1
2183
+ )
2184
+ if serial:
2185
+ for file_path in files:
2186
+ if _interrupted:
2187
+ warn("Interrupted! Stopping...")
2188
+ break
2189
+ self.process_file(file_path)
2190
+ return
2191
+ with ThreadPoolExecutor(max_workers=self.settings.parallel) as pool:
2192
+ futures = {pool.submit(self.process_file, f): f for f in files}
2193
+ for future in as_completed(futures):
2194
+ if _interrupted:
2195
+ for pending in futures:
2196
+ pending.cancel()
2197
+ warn("Interrupted! Cancelling remaining tasks...")
2198
+ break
2199
+ exc = future.exception()
2200
+ if exc:
2201
+ warn(f"Worker failed on {futures[future]}: {exc}")
2202
+ self.stats.inc("errors")
2203
+
2204
+ def run(self) -> int:
2205
+ settings = self.settings
2206
+ if settings.file:
2207
+ target = Path(settings.file)
2208
+ if not target.is_absolute():
2209
+ target = self.root / settings.file
2210
+ if not target.is_file():
2211
+ error_exit(f"File not found: {settings.file}")
2212
+ self.process_file(target)
2213
+ elif settings.collection and settings.collection != "all":
2214
+ path = self.collection_path(settings.collection)
2215
+ if not path.is_dir():
2216
+ available = ", ".join(settings.collections)
2217
+ error_exit(
2218
+ f"Unknown collection: {settings.collection}. "
2219
+ f"Available: {available}, all"
2220
+ )
2221
+ step(f"Processing {settings.collection} collection...")
2222
+ self.process_collection(path)
2223
+ else:
2224
+ step("Processing all configured collections...")
2225
+ for name in settings.collections:
2226
+ path = self.collection_path(name)
2227
+ if path.is_dir():
2228
+ step(f"Processing {name} collection...")
2229
+ self.process_collection(path)
2230
+ else:
2231
+ warn(f"Collection directory not found: {path}")
2232
+
2233
+ print()
2234
+ print_header("📊 Summary")
2235
+ print(f" Files processed: {self.stats.processed}")
2236
+ print(f" Images generated: {self.stats.generated}")
2237
+ print(f" Images enhanced: {self.stats.enhanced}")
2238
+ print(f" Files skipped: {self.stats.skipped}")
2239
+ print(f" Errors: {self.stats.errors}")
2240
+ print()
2241
+
2242
+ if settings.dry_run:
2243
+ info("This was a dry run. No actual changes were made.")
2244
+ if self.stats.errors > 0:
2245
+ warn("Some files had errors. Check the output above.")
2246
+ return 1
2247
+ success("Preview image generation complete!")
2248
+ return 0
2249
+
2250
+
2251
+ # =============================================================================
2252
+ # CLI
2253
+ # =============================================================================
2254
+
2255
+ def build_arg_parser() -> argparse.ArgumentParser:
2256
+ parser = argparse.ArgumentParser(
2257
+ prog="zer0-image-generator",
2258
+ description="AI preview/social-image generator for Jekyll sites — "
2259
+ "Claude analyzes & reviews, a renderer produces "
2260
+ "(openai [default], xai, stability, gemini, local)",
2261
+ )
2262
+ parser.add_argument("-d", "--dry-run", action="store_true",
2263
+ help="Preview what would be generated (no changes)")
2264
+ parser.add_argument("-v", "--verbose", action="store_true",
2265
+ help="Enable verbose output")
2266
+ parser.add_argument("-f", "--file", help="Process a specific file only")
2267
+ parser.add_argument("-c", "--collection",
2268
+ help="Process one collection by name, or 'all' for every "
2269
+ "configured collection")
2270
+ parser.add_argument("-p", "--provider",
2271
+ choices=sorted(PROVIDERS.keys()),
2272
+ help="AI provider (default: claude, via _config.yml)")
2273
+ parser.add_argument("--model", help="Override the image/SVG model for the provider")
2274
+ parser.add_argument("--output-dir",
2275
+ help="Output directory for images (default: assets/images/previews)")
2276
+ parser.add_argument("--force", action="store_true",
2277
+ help="Regenerate images even if preview exists")
2278
+ parser.add_argument("--list-missing", action="store_true",
2279
+ help="Only list files with missing previews")
2280
+ parser.add_argument("-j", "--parallel", "-w", "--workers", type=int, default=None,
2281
+ dest="parallel", metavar="N",
2282
+ help="Concurrent workers (default 4; serial for dry-run/list)")
2283
+ parser.add_argument("-e", "--enhance", action="store_true",
2284
+ help="Enhance existing preview images (OpenAI images/edits)")
2285
+ parser.add_argument("--enhance-prompt", help="Custom enhancement prompt (implies --enhance)")
2286
+ parser.add_argument("--enhance-model", help="Model for enhancement (default: gpt-image-2)")
2287
+ parser.add_argument("--enhance-quality", choices=["low", "medium", "high", "auto"],
2288
+ help="Enhancement quality (default: auto)")
2289
+ parser.add_argument("--enhance-fidelity", choices=["high", "low"],
2290
+ help="Input fidelity (implies --enhance)")
2291
+ parser.add_argument("--enhance-format", choices=["png", "jpeg", "webp"],
2292
+ help="Enhanced output format (implies --enhance)")
2293
+ parser.add_argument("--prompt-engine", choices=["template", "claude"],
2294
+ help="Art-direction brief: claude analyzes the article "
2295
+ "(default) or template uses the built-in prompt")
2296
+ parser.add_argument("--review", choices=["claude", "none"],
2297
+ help="Post-render review: claude inspects the image and "
2298
+ "may request one refined regeneration (default: claude)")
2299
+ parser.add_argument("--rasterizer",
2300
+ choices=["auto", "rsvg", "inkscape", "magick", "playwright", "none"],
2301
+ help="SVG→PNG tool for claude/local providers (default: auto)")
2302
+ parser.add_argument("--style", help="Override image style prompt")
2303
+ parser.add_argument("--assets-prefix", help="Assets prefix for path normalization")
2304
+ parser.add_argument("--no-auto-prefix", action="store_true",
2305
+ help="Disable automatic assets prefix prepending")
2306
+ parser.add_argument("--collections-dir", dest="collections_dir",
2307
+ help="Directory holding _<collection> dirs (default: Jekyll's "
2308
+ "top-level collections_dir, else the site root)")
2309
+ parser.add_argument("--front-matter-key", dest="front_matter_key",
2310
+ help="Front-matter key to read/write (default: preview; "
2311
+ "jekyll-seo-tag sites typically use image)")
2312
+ parser.add_argument("--authors-file", dest="authors_file",
2313
+ help="Author-overrides YAML relative to the site root "
2314
+ "(default: _data/authors.yml; pass '' to disable)")
2315
+ parser.add_argument("--batch", type=int, default=0,
2316
+ help="Limit number of files processed (0 = no limit)")
2317
+ parser.add_argument("--log-file", help="Also write log output to a file")
2318
+ # Accepted for backward compatibility with the previous engine's CLI;
2319
+ # pacing is now handled by with_retries (Retry-After aware) + -j workers.
2320
+ parser.add_argument("--rate-limit", type=int, dest="rate_limit",
2321
+ help=argparse.SUPPRESS)
2322
+ return parser
2323
+
2324
+
2325
+ def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
2326
+ args = build_arg_parser().parse_args(argv)
2327
+ # Historical flag semantics: these imply --enhance (--enhance-model does not).
2328
+ if args.enhance_prompt or args.enhance_fidelity or args.enhance_format:
2329
+ args.enhance = True
2330
+ return args
2331
+
2332
+
2333
+ def validate_credentials(settings: Settings, ctx: RunContext) -> None:
2334
+ """Credential checks are skipped for --list-missing/--dry-run (historical
2335
+ behavior), but an unknown provider name (from AI_PROVIDER / _config.yml —
2336
+ argparse already constrains -p) errors in every mode."""
2337
+ provider = PROVIDERS.get(settings.provider)
2338
+ if provider is None:
2339
+ error_exit(f"Unknown AI provider: {settings.provider}. "
2340
+ f"Available: {', '.join(sorted(PROVIDERS))}")
2341
+ if settings.list_only or settings.dry_run:
2342
+ return
2343
+ if provider.name == "local":
2344
+ info("Using local provider - no API key required")
2345
+ return
2346
+ if not provider.is_configured(ctx.env):
2347
+ error_exit(provider.missing_hint(ctx.env))
2348
+
2349
+
2350
+ def main(argv: Optional[List[str]] = None) -> int:
2351
+ global VERBOSE, _log_file
2352
+
2353
+ signal.signal(signal.SIGINT, _signal_handler)
2354
+ signal.signal(signal.SIGTERM, _signal_handler)
2355
+
2356
+ args = parse_args(argv)
2357
+ ensure_yaml()
2358
+ _load_dotenv()
2359
+
2360
+ project_root = find_project_root()
2361
+ site_config = read_config(project_root)
2362
+ settings = resolve_settings(args, site_config)
2363
+ VERBOSE = settings.verbose
2364
+
2365
+ if args.log_file:
2366
+ try:
2367
+ _log_file = open(args.log_file, "w", encoding="utf-8")
2368
+ info(f"Logging to: {args.log_file}")
2369
+ except OSError as exc:
2370
+ warn(f"Cannot open log file: {exc}")
2371
+
2372
+ explicitly_targeted = (
2373
+ settings.file or settings.collection or settings.enhance
2374
+ or settings.provider_explicit
2375
+ )
2376
+ if not settings.enabled and not explicitly_targeted:
2377
+ info("preview_images.enabled is false in _config.yml — nothing to do "
2378
+ "(pass --provider, --file or --collection to override).")
2379
+ return 0
2380
+
2381
+ print_header("🎨 Preview Image Generator")
2382
+ ctx = RunContext(project_root=project_root, env=dict(os.environ))
2383
+ validate_credentials(settings, ctx)
2384
+
2385
+ # Claude orchestration (analyze/review) degrades gracefully: without a
2386
+ # Claude credential the run continues on template prompts, unreviewed.
2387
+ wants_claude = (
2388
+ settings.provider != "local"
2389
+ and "claude" in (settings.prompt_engine, settings.review_engine)
2390
+ and not (settings.dry_run or settings.list_only)
2391
+ )
2392
+ if wants_claude:
2393
+ if ctx.claude().available():
2394
+ info(f"Claude orchestration: {ctx.claude().describe()}")
2395
+ else:
2396
+ warn(CLAUDE_CREDENTIAL_HINT)
2397
+ settings = replace(settings, prompt_engine="template", review_engine="none")
2398
+
2399
+ output_dir = project_root / settings.output_dir
2400
+ if not settings.dry_run and not settings.list_only:
2401
+ output_dir.mkdir(parents=True, exist_ok=True)
2402
+
2403
+ info("Configuration:")
2404
+ print(f" AI Provider: {settings.provider}")
2405
+ print(f" Image Model: {settings.model or PROVIDERS[settings.provider].default_model()}")
2406
+ print(f" Output Dir: {settings.output_dir}")
2407
+ print(f" Image Size: {settings.size}")
2408
+ print(f" Parallel Workers: {settings.parallel}")
2409
+ print(f" Dry Run: {str(settings.dry_run).lower()}")
2410
+ print(f" Force: {str(settings.force).lower()}")
2411
+ print(f" List Only: {str(settings.list_only).lower()}")
2412
+ print(f" Prompt Engine: {settings.prompt_engine}")
2413
+ print(f" Review: {settings.review_engine}")
2414
+ if settings.enhance:
2415
+ print(" Mode: ENHANCE (improve existing images)")
2416
+ print(f" Enhance Model: {settings.enhance_model}")
2417
+ print(f" Enhance Quality: {settings.enhance_quality}")
2418
+ print(f" Input Fidelity: {settings.enhance_fidelity}")
2419
+ print(f" Output Format: {settings.enhance_format}")
2420
+ if settings.enhance_prompt:
2421
+ print(f" Custom Prompt: {settings.enhance_prompt[:80]}...")
2422
+ else:
2423
+ print(" Prompt: (default improvement prompt)")
2424
+ print()
2425
+
2426
+ runner = Runner(settings, project_root, ctx=ctx)
2427
+ exit_code = runner.run()
2428
+
2429
+ if _log_file:
2430
+ _log_file.close()
2431
+ return exit_code
2432
+
2433
+
2434
+ if __name__ == "__main__":
2435
+ sys.exit(main())