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