jekyll-theme-zer0 1.26.0 → 1.27.0

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