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