zer0-image-generator 0.6.0 → 0.8.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 +20 -0
- data/README.md +106 -2
- data/lib/zer0_image_generator/abc/style_pack.rb +145 -0
- data/lib/zer0_image_generator/abc.rb +75 -0
- data/lib/zer0_image_generator/all.rb +57 -0
- data/lib/zer0_image_generator/claude/client.rb +291 -0
- data/lib/zer0_image_generator/claude/orchestration.rb +128 -0
- data/lib/zer0_image_generator/cli.rb +318 -0
- data/lib/zer0_image_generator/config.rb +192 -0
- data/lib/zer0_image_generator/constants.rb +177 -0
- data/lib/zer0_image_generator/content.rb +379 -0
- data/lib/zer0_image_generator/engine.rb +21 -0
- data/lib/zer0_image_generator/freesvg/cache.rb +167 -0
- data/lib/zer0_image_generator/freesvg/client.rb +467 -0
- data/lib/zer0_image_generator/http.rb +264 -0
- data/lib/zer0_image_generator/library.rb +201 -0
- data/lib/zer0_image_generator/logging.rb +236 -0
- data/lib/zer0_image_generator/preview_generator.py +1072 -100
- data/lib/zer0_image_generator/prompt.rb +48 -0
- data/lib/zer0_image_generator/providers/base.rb +152 -0
- data/lib/zer0_image_generator/providers/gemini.rb +60 -0
- data/lib/zer0_image_generator/providers/local.rb +90 -0
- data/lib/zer0_image_generator/providers/openai.rb +102 -0
- data/lib/zer0_image_generator/providers/stability.rb +64 -0
- data/lib/zer0_image_generator/providers/xai.rb +81 -0
- data/lib/zer0_image_generator/providers/xai_auth.rb +270 -0
- data/lib/zer0_image_generator/providers.rb +22 -0
- data/lib/zer0_image_generator/runner.rb +573 -0
- data/lib/zer0_image_generator/settings.rb +386 -0
- data/lib/zer0_image_generator/stats.rb +61 -0
- data/lib/zer0_image_generator/support/py_random.rb +189 -0
- data/lib/zer0_image_generator/svg/banner_seed.rb +241 -0
- data/lib/zer0_image_generator/svg/generators/flowfield.rb +154 -0
- data/lib/zer0_image_generator/svg/generators/invaders.rb +125 -0
- data/lib/zer0_image_generator/svg/generators/lowpoly.rb +254 -0
- data/lib/zer0_image_generator/svg/generators/lsystem.rb +228 -0
- data/lib/zer0_image_generator/svg/generators/mandala.rb +155 -0
- data/lib/zer0_image_generator/svg/generators/pixelquest.rb +144 -0
- data/lib/zer0_image_generator/svg/generators/starmap.rb +179 -0
- data/lib/zer0_image_generator/svg/lint.rb +400 -0
- data/lib/zer0_image_generator/svg/local_renderer.rb +606 -0
- data/lib/zer0_image_generator/svg/pixel_kit.rb +167 -0
- data/lib/zer0_image_generator/svg/rasterizer.rb +196 -0
- data/lib/zer0_image_generator/svg/sanitizer.rb +159 -0
- data/lib/zer0_image_generator/version.rb +1 -1
- metadata +43 -2
|
@@ -88,6 +88,7 @@ import zlib
|
|
|
88
88
|
import xml.etree.ElementTree as ET
|
|
89
89
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
90
90
|
from dataclasses import dataclass, field, replace
|
|
91
|
+
from datetime import datetime, timezone
|
|
91
92
|
from pathlib import Path
|
|
92
93
|
from typing import Any, Dict, List, Optional, Tuple
|
|
93
94
|
|
|
@@ -130,6 +131,10 @@ DEFAULTS: Dict[str, Any] = {
|
|
|
130
131
|
"claude_model": "", # empty → DEFAULT_CLAUDE_MODEL
|
|
131
132
|
"claude_effort": "low", # output_config.effort for brief/review calls
|
|
132
133
|
# (short creative tasks; "" sends no effort)
|
|
134
|
+
"rasterizer": "auto", # SVG→PNG tool for the SVG providers (local,
|
|
135
|
+
# claude): auto|rsvg|inkscape|magick|playwright
|
|
136
|
+
# |none. "none" is SVG-only mode — the vector
|
|
137
|
+
# banner IS the deliverable and no PNG is made.
|
|
133
138
|
}
|
|
134
139
|
|
|
135
140
|
# Enhance mode (OpenAI /v1/images/edits — see OpenAIProvider.edit)
|
|
@@ -616,6 +621,33 @@ def collection_of(path: Path) -> str:
|
|
|
616
621
|
return ""
|
|
617
622
|
|
|
618
623
|
|
|
624
|
+
def section_of(path: Path) -> str:
|
|
625
|
+
"""The file's section — the directory immediately INSIDE its collection
|
|
626
|
+
(`pages/_posts/hacks/x.md` -> 'hacks') — or '' when the collection holds
|
|
627
|
+
its files flat. Sites that fold several kinds of writing into one
|
|
628
|
+
collection carry the editorial distinction here, and it is the strongest
|
|
629
|
+
single signal for what a banner should look like."""
|
|
630
|
+
resolved = Path(path).resolve()
|
|
631
|
+
parts = resolved.parts
|
|
632
|
+
for i, name in enumerate(parts):
|
|
633
|
+
if name.startswith("_") and len(name) > 1:
|
|
634
|
+
# parts[i+1] is a directory only when something follows it.
|
|
635
|
+
return parts[i + 1] if i + 2 < len(parts) else ""
|
|
636
|
+
return ""
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def article_art_context(cf: "ContentFile", slug: str) -> Dict[str, str]:
|
|
640
|
+
"""The page context the local renderer derives its art from."""
|
|
641
|
+
return {
|
|
642
|
+
"slug": slug,
|
|
643
|
+
"section": section_of(cf.path) or collection_of(cf.path),
|
|
644
|
+
"title": cf.title or "",
|
|
645
|
+
"description": cf.description or "",
|
|
646
|
+
"categories": cf.categories or "",
|
|
647
|
+
"body": cf.content or "",
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
|
|
619
651
|
def collection_preview_overrides(
|
|
620
652
|
collection_styles: Dict[str, Any], collection: str
|
|
621
653
|
) -> Dict[str, str]:
|
|
@@ -782,7 +814,7 @@ def resolve_settings(args: argparse.Namespace, config: Dict[str, Any]) -> Settin
|
|
|
782
814
|
args.enhance_format or os.environ.get("ENHANCE_FORMAT")
|
|
783
815
|
or ENHANCE_DEFAULTS["format"]
|
|
784
816
|
),
|
|
785
|
-
rasterizer=args.rasterizer
|
|
817
|
+
rasterizer=pick(args.rasterizer, "IMAGE_RASTERIZER", "rasterizer"),
|
|
786
818
|
provider_explicit=args.provider is not None or bool(os.environ.get("AI_PROVIDER")),
|
|
787
819
|
)
|
|
788
820
|
return settings
|
|
@@ -1280,79 +1312,523 @@ def sanitize_svg(svg_text: str) -> Tuple[str, List[str]]:
|
|
|
1280
1312
|
return ET.tostring(root, encoding="unicode"), warnings
|
|
1281
1313
|
|
|
1282
1314
|
|
|
1283
|
-
|
|
1284
|
-
|
|
1315
|
+
# --- Content-aware banner art -----------------------------------------------
|
|
1316
|
+
# The `local` renderer used to draw ONE composition — a retro skyline under a
|
|
1317
|
+
# sun — re-coloured per slug. Every banner on a site therefore looked like
|
|
1318
|
+
# every other banner, and none of them had anything to do with what the article
|
|
1319
|
+
# said. These compositions replace it: the article's own context (its section,
|
|
1320
|
+
# tags, title and body) picks the composition AND the palette, so a shell hack
|
|
1321
|
+
# renders as a circuit schematic and a narrative essay as a flow field.
|
|
1322
|
+
#
|
|
1323
|
+
# Everything here is authored at the banner frame (SVG_WIDTH x SVG_HEIGHT) so
|
|
1324
|
+
# sanitize_svg's forced viewBox is a no-op rather than a squash, and every draw
|
|
1325
|
+
# goes through the seeded LCG — no clock, no `random`, no environment. Same
|
|
1326
|
+
# article in, same bytes out.
|
|
1327
|
+
|
|
1328
|
+
BANNER_PALETTES: Dict[str, List[str]] = {
|
|
1329
|
+
# [bg, far, mid, near, accent, glow]
|
|
1330
|
+
"neon": ["#0b0f17", "#171b2e", "#2b2a5e", "#d946ef", "#22d3ee", "#f5f3ff"],
|
|
1331
|
+
"ember": ["#120b16", "#2d1b2e", "#5c2a4a", "#b3324b", "#f2542d", "#ffd166"],
|
|
1332
|
+
"cyanic": ["#0b132b", "#1c2541", "#1c7293", "#5bc0be", "#9fd8d6", "#e8f1f2"],
|
|
1333
|
+
"moss": ["#101a14", "#1b2f22", "#2f5d3f", "#6f9e6f", "#a3c9a8", "#e9edc9"],
|
|
1334
|
+
"dusk": ["#1a1423", "#2c1f3d", "#5c3a72", "#a05fa8", "#f5a623", "#f7e8d3"],
|
|
1335
|
+
"slate": ["#0d1117", "#161b22", "#26303d", "#4b6178", "#7ea6c9", "#dbe7f0"],
|
|
1336
|
+
}
|
|
1337
|
+
BANNER_PALETTE_ORDER: List[str] = ["neon", "ember", "cyanic", "moss", "dusk", "slate"]
|
|
1338
|
+
|
|
1339
|
+
# Keyword -> composition. Scored against the article's section, tags, title and
|
|
1340
|
+
# body; the highest score wins, ties break on the content seed. Kept small and
|
|
1341
|
+
# legible on purpose — this is art direction, not a classifier.
|
|
1342
|
+
# Matched as WHOLE WORDS, so section names must appear in the form a section
|
|
1343
|
+
# directory actually uses ('hacks', not 'hack') alongside the topical terms.
|
|
1344
|
+
BANNER_KEYWORDS: List[Tuple[str, Tuple[str, ...]]] = [
|
|
1345
|
+
("circuit", ("hack", "hacks", "shell", "bash", "zsh", "cli", "terminal",
|
|
1346
|
+
"command", "script", "docker", "container", "pipeline",
|
|
1347
|
+
"deploy", "git", "kernel", "server", "network", "port",
|
|
1348
|
+
"cache", "sed", "awk", "grep", "ssh", "make", "compile")),
|
|
1349
|
+
("pixelscene", ("tool", "tools", "review", "reviews", "app", "editor",
|
|
1350
|
+
"game", "retro", "install", "cheatsheet", "productivity",
|
|
1351
|
+
"keyboard", "plugin", "extension", "terminal")),
|
|
1352
|
+
("starfield", ("security", "threat", "secret", "secrets", "token", "auth",
|
|
1353
|
+
"permission", "permissions", "audit", "risk", "attack",
|
|
1354
|
+
"privacy", "explore", "journey", "discover", "vulnerability",
|
|
1355
|
+
"credential", "sandbox")),
|
|
1356
|
+
("lowpoly", ("doc", "docs", "guide", "reference", "architecture", "design",
|
|
1357
|
+
"system", "structure", "schema", "diagram", "overview", "spec",
|
|
1358
|
+
"concept", "concepts", "glossary", "handbook")),
|
|
1359
|
+
("flowfield", ("note", "notes", "essay", "story", "ai", "agent", "agents",
|
|
1360
|
+
"robot", "automation", "loop", "flow", "process", "thought",
|
|
1361
|
+
"retrospective", "lesson", "lessons", "journal", "reflection")),
|
|
1362
|
+
]
|
|
1363
|
+
BANNER_COMPOSITION_ORDER: List[str] = [
|
|
1364
|
+
"circuit", "flowfield", "lowpoly", "pixelscene", "starfield",
|
|
1365
|
+
]
|
|
1285
1366
|
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1367
|
+
|
|
1368
|
+
def _banner_lcg(seed: int):
|
|
1369
|
+
"""glibc LCG masked to 31 bits — the engine's long-standing determinism
|
|
1370
|
+
seam, reused so banner art draws from the same stream shape as the rest."""
|
|
1371
|
+
state = seed or 1
|
|
1291
1372
|
|
|
1292
1373
|
def nxt(bound: int) -> int:
|
|
1293
|
-
nonlocal
|
|
1294
|
-
|
|
1295
|
-
return
|
|
1374
|
+
nonlocal state
|
|
1375
|
+
state = (state * 1103515245 + 12345) & 0x7FFFFFFF
|
|
1376
|
+
return state % max(bound, 1)
|
|
1377
|
+
return nxt
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
def _num(value: float, places: int = 1) -> str:
|
|
1381
|
+
"""Round for markup. Sub-pixel precision is invisible and triples size."""
|
|
1382
|
+
text = f"{value:.{places}f}".rstrip("0").rstrip(".")
|
|
1383
|
+
return text or "0"
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def _mix(a: str, b: str, t: float) -> str:
|
|
1387
|
+
"""Blend two #rrggbb strings. Integer output, so the bytes are identical
|
|
1388
|
+
on every runtime regardless of float formatting."""
|
|
1389
|
+
t = min(max(t, 0.0), 1.0)
|
|
1390
|
+
out = "#"
|
|
1391
|
+
for i in (1, 3, 5):
|
|
1392
|
+
ca, cb = int(a[i:i + 2], 16), int(b[i:i + 2], 16)
|
|
1393
|
+
out += f"{int(ca + (cb - ca) * t + 0.5):02x}"
|
|
1394
|
+
return out
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
# Trig by lookup table, built from a Taylor series rather than imported from
|
|
1398
|
+
# math: libm rounds the last bit differently across platforms and runtimes, and
|
|
1399
|
+
# the Ruby port is pinned byte-for-byte against this file. A 256-entry table is
|
|
1400
|
+
# 1.4 degrees of resolution — finer than a 1px step can express at this size.
|
|
1401
|
+
_BANNER_SIN: List[float] = []
|
|
1402
|
+
_BANNER_COS: List[float] = []
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def _build_banner_trig() -> None:
|
|
1406
|
+
tau = 6.283185307179586
|
|
1407
|
+
for i in range(256):
|
|
1408
|
+
a = (i / 256.0) * tau
|
|
1409
|
+
t = a - tau if a > 3.141592653589793 else a
|
|
1410
|
+
_BANNER_SIN.append(
|
|
1411
|
+
t - t ** 3 / 6 + t ** 5 / 120 - t ** 7 / 5040 + t ** 9 / 362880)
|
|
1412
|
+
_BANNER_COS.append(
|
|
1413
|
+
1 - t ** 2 / 2 + t ** 4 / 24 - t ** 6 / 720 + t ** 8 / 40320
|
|
1414
|
+
- t ** 10 / 3628800)
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
_build_banner_trig()
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
def _bsin(angle: float) -> float:
|
|
1421
|
+
return _BANNER_SIN[int(angle / 6.283185307179586 * 256) & 255]
|
|
1422
|
+
|
|
1423
|
+
|
|
1424
|
+
def _bcos(angle: float) -> float:
|
|
1425
|
+
return _BANNER_COS[int(angle / 6.283185307179586 * 256) & 255]
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
def _banner_noise(seed: int):
|
|
1429
|
+
"""Smoothed value noise on a 17x17 lattice, sampled in unit coordinates."""
|
|
1430
|
+
nxt = _banner_lcg(seed ^ 0x9E3779B9)
|
|
1431
|
+
grid = [[nxt(1000) / 1000.0 for _ in range(17)] for _ in range(17)]
|
|
1432
|
+
|
|
1433
|
+
def at(x: float, y: float) -> float:
|
|
1434
|
+
gx, gy = x * 16.0, y * 16.0
|
|
1435
|
+
x0, y0 = int(gx), int(gy)
|
|
1436
|
+
fx, fy = gx - x0, gy - y0
|
|
1437
|
+
x1, y1 = min(x0 + 1, 16), min(y0 + 1, 16)
|
|
1438
|
+
# smoothstep, so the field has no directional creases
|
|
1439
|
+
sx, sy = fx * fx * (3 - 2 * fx), fy * fy * (3 - 2 * fy)
|
|
1440
|
+
a = grid[y0][x0] * (1 - sx) + grid[y0][x1] * sx
|
|
1441
|
+
b = grid[y1][x0] * (1 - sx) + grid[y1][x1] * sx
|
|
1442
|
+
return a * (1 - sy) + b * sy
|
|
1443
|
+
return at
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
def _banner_frame(body: str, pal: List[str], title: str = "") -> str:
|
|
1447
|
+
"""Wrap a composition in the banner root. `role="img"` plus a `<title>` as
|
|
1448
|
+
the FIRST child is the house rule (.claude/rules/svg.md) — without it a
|
|
1449
|
+
screen reader announces an unlabeled graphic."""
|
|
1450
|
+
label = (str(title) or "banner").replace("&", "&") \
|
|
1451
|
+
.replace("<", "<").replace(">", ">")
|
|
1452
|
+
return (
|
|
1453
|
+
f'<svg xmlns="{SVG_NS}" viewBox="0 0 {SVG_WIDTH} {SVG_HEIGHT}" '
|
|
1454
|
+
f'width="{SVG_WIDTH}" height="{SVG_HEIGHT}" role="img">'
|
|
1455
|
+
f'<title>Preview banner: {label}</title>'
|
|
1456
|
+
f'<rect width="{SVG_WIDTH}" height="{SVG_HEIGHT}" fill="{pal[0]}" />'
|
|
1457
|
+
f'{body}</svg>'
|
|
1458
|
+
)
|
|
1459
|
+
|
|
1296
1460
|
|
|
1461
|
+
def banner_flowfield(seed: int, pal: List[str], title: str = "") -> str:
|
|
1462
|
+
"""Streamlines advected through a noise field — organic and dense, with no
|
|
1463
|
+
dead space anywhere in the frame."""
|
|
1464
|
+
nxt = _banner_lcg(seed)
|
|
1465
|
+
noise = _banner_noise(seed)
|
|
1297
1466
|
w, h = SVG_WIDTH, SVG_HEIGHT
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1467
|
+
inks = [pal[3], pal[4], pal[5], pal[2]]
|
|
1468
|
+
out: List[str] = []
|
|
1469
|
+
for _ in range(400):
|
|
1470
|
+
x, y = nxt(w + 240) - 120, nxt(h + 240) - 120
|
|
1471
|
+
pts = [(x, y)]
|
|
1472
|
+
# Advance in SMALL steps so the curve stays faithful to the field, but
|
|
1473
|
+
# record only every third one. Mapping the 0..1 noise onto a full turn
|
|
1474
|
+
# (not two) keeps adjacent steps from flipping ~180 degrees, which is
|
|
1475
|
+
# what would otherwise stutter the line into a zigzag.
|
|
1476
|
+
for i in range(96):
|
|
1477
|
+
angle = noise(min(max(x / w, 0.0), 1.0),
|
|
1478
|
+
min(max(y / h, 0.0), 1.0)) * 6.283185307179586
|
|
1479
|
+
x += _bcos(angle) * 9
|
|
1480
|
+
y += _bsin(angle) * 9
|
|
1481
|
+
if not (-140 < x < w + 140 and -140 < y < h + 140):
|
|
1482
|
+
break
|
|
1483
|
+
if i % 3 == 2:
|
|
1484
|
+
pts.append((x, y))
|
|
1485
|
+
if len(pts) < 5:
|
|
1486
|
+
continue
|
|
1487
|
+
ink = inks[nxt(len(inks))]
|
|
1488
|
+
width = 0.5 + nxt(18) / 10.0
|
|
1489
|
+
opacity = 0.16 + nxt(50) / 100.0
|
|
1490
|
+
# Whole pixels: at these stroke widths a decimal is invisible and costs
|
|
1491
|
+
# two bytes on each of ~12k coordinate pairs.
|
|
1492
|
+
d = "M" + " L".join(f"{int(px)} {int(py)}" for px, py in pts)
|
|
1493
|
+
out.append(f'<path d="{d}" fill="none" stroke="{ink}" '
|
|
1494
|
+
f'stroke-width="{_num(width)}" '
|
|
1495
|
+
f'stroke-opacity="{_num(opacity, 2)}" '
|
|
1496
|
+
f'stroke-linecap="round" />')
|
|
1497
|
+
return _banner_frame("".join(out), pal, title)
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
def banner_lowpoly(seed: int, pal: List[str], title: str = "") -> str:
|
|
1501
|
+
"""Faceted gradient field — calm and technical, fills the frame edge to
|
|
1502
|
+
edge."""
|
|
1503
|
+
nxt = _banner_lcg(seed)
|
|
1504
|
+
noise = _banner_noise(seed)
|
|
1505
|
+
w, h = SVG_WIDTH, SVG_HEIGHT
|
|
1506
|
+
cols, rows = 22, 15
|
|
1507
|
+
cw, ch = w / cols, h / rows
|
|
1508
|
+
lattice = []
|
|
1509
|
+
for r in range(rows + 1):
|
|
1510
|
+
row = []
|
|
1511
|
+
for c in range(cols + 1):
|
|
1512
|
+
# Edge vertices stay pinned, or the border shows through.
|
|
1513
|
+
jx = 0.0 if c in (0, cols) else (nxt(60) - 30) / 100.0 * cw
|
|
1514
|
+
jy = 0.0 if r in (0, rows) else (nxt(60) - 30) / 100.0 * ch
|
|
1515
|
+
row.append((c * cw + jx, r * ch + jy))
|
|
1516
|
+
lattice.append(row)
|
|
1517
|
+
|
|
1518
|
+
# Ramp CONTINUOUSLY down the frame and let the noise only shade each facet
|
|
1519
|
+
# slightly lighter or darker. Discrete colour stops band the field into
|
|
1520
|
+
# hard stripes and an accent threshold scatters floating shards.
|
|
1521
|
+
ramp = [pal[1], pal[2], pal[3], pal[4]]
|
|
1522
|
+
|
|
1523
|
+
def facet_color(t: float, n: float) -> str:
|
|
1524
|
+
pos = min(max(t, 0.0), 0.999) * (len(ramp) - 1)
|
|
1525
|
+
i = int(pos)
|
|
1526
|
+
base = _mix(ramp[i], ramp[i + 1], pos - i)
|
|
1527
|
+
if n > 0.5:
|
|
1528
|
+
return _mix(base, pal[5], (n - 0.5) * 0.22)
|
|
1529
|
+
return _mix(base, pal[0], (0.5 - n) * 0.30)
|
|
1530
|
+
|
|
1531
|
+
out: List[str] = []
|
|
1532
|
+
for r in range(rows):
|
|
1533
|
+
for c in range(cols):
|
|
1534
|
+
a, b = lattice[r][c], lattice[r][c + 1]
|
|
1535
|
+
d, e = lattice[r + 1][c], lattice[r + 1][c + 1]
|
|
1536
|
+
for tri in ((a, b, d), (b, e, d)):
|
|
1537
|
+
cx = (tri[0][0] + tri[1][0] + tri[2][0]) / 3
|
|
1538
|
+
cy = (tri[0][1] + tri[1][1] + tri[2][1]) / 3
|
|
1539
|
+
color = facet_color(cy / h, noise(cx / w, cy / h))
|
|
1540
|
+
points = " ".join(f"{_num(px)},{_num(py)}" for px, py in tri)
|
|
1541
|
+
# Stroke each facet in its own fill: float rounding otherwise
|
|
1542
|
+
# leaves hairline seams that render as stray light lines.
|
|
1543
|
+
out.append(f'<polygon points="{points}" fill="{color}" '
|
|
1544
|
+
f'stroke="{color}" stroke-width="1" />')
|
|
1545
|
+
return _banner_frame("".join(out), pal, title)
|
|
1546
|
+
|
|
1547
|
+
|
|
1548
|
+
def banner_circuit(seed: int, pal: List[str], title: str = "") -> str:
|
|
1549
|
+
"""Orthogonal traces, pads, vias and chips — a schematic read for the
|
|
1550
|
+
shell/infrastructure end of a site."""
|
|
1551
|
+
nxt = _banner_lcg(seed)
|
|
1552
|
+
w, h = SVG_WIDTH, SVG_HEIGHT
|
|
1553
|
+
step = 32
|
|
1554
|
+
out: List[str] = []
|
|
1555
|
+
|
|
1556
|
+
substrate = []
|
|
1557
|
+
for x in range(0, w + 1, step * 2):
|
|
1558
|
+
substrate.append(f"M{x} 0 L{x} {h}")
|
|
1559
|
+
for y in range(0, h + 1, step * 2):
|
|
1560
|
+
substrate.append(f"M0 {y} L{w} {y}")
|
|
1561
|
+
out.append(f'<path d="{" ".join(substrate)}" stroke="{pal[1]}" '
|
|
1562
|
+
f'stroke-width="1" fill="none" stroke-opacity="0.9" />')
|
|
1563
|
+
|
|
1564
|
+
junctions: List[Tuple[int, int]] = []
|
|
1565
|
+
for _ in range(64):
|
|
1566
|
+
# Snap to the grid so every trace is orthogonal by construction.
|
|
1567
|
+
x = nxt(w // step) * step
|
|
1568
|
+
y = nxt(h // step) * step
|
|
1569
|
+
segments = [f"M{x} {y}"]
|
|
1570
|
+
junctions.append((x, y))
|
|
1571
|
+
for _ in range(3 + nxt(5)):
|
|
1572
|
+
if nxt(2):
|
|
1573
|
+
x = max(0, min(w, x + (nxt(11) - 5) * step))
|
|
1574
|
+
segments.append(f"L{x} {y}")
|
|
1575
|
+
else:
|
|
1576
|
+
y = max(0, min(h, y + (nxt(9) - 4) * step))
|
|
1577
|
+
segments.append(f"L{x} {y}")
|
|
1578
|
+
junctions.append((x, y))
|
|
1579
|
+
ink = pal[4] if nxt(3) else pal[3]
|
|
1580
|
+
out.append(f'<path d="{" ".join(segments)}" fill="none" stroke="{ink}" '
|
|
1581
|
+
f'stroke-width="{2 + nxt(3)}" stroke-opacity="0.85" '
|
|
1582
|
+
f'stroke-linecap="square" stroke-linejoin="miter" />')
|
|
1583
|
+
|
|
1584
|
+
for (px, py) in junctions:
|
|
1585
|
+
out.append(f'<circle cx="{px}" cy="{py}" r="7" fill="{pal[0]}" '
|
|
1586
|
+
f'stroke="{pal[4]}" stroke-width="3" />')
|
|
1587
|
+
|
|
1588
|
+
for _ in range(6):
|
|
1589
|
+
x = nxt(w // step - 6) * step
|
|
1590
|
+
y = nxt(h // step - 4) * step
|
|
1591
|
+
cw, chh = (3 + nxt(3)) * step, (2 + nxt(2)) * step
|
|
1592
|
+
out.append(f'<rect x="{x}" y="{y}" width="{cw}" height="{chh}" rx="6" '
|
|
1593
|
+
f'fill="{pal[2]}" stroke="{pal[5]}" stroke-width="2" />')
|
|
1594
|
+
for leg in range(cw // step * 2):
|
|
1595
|
+
out.append(f'<rect x="{x + 8 + leg * (step // 2)}" y="{y + chh}" '
|
|
1596
|
+
f'width="6" height="10" fill="{pal[5]}" />')
|
|
1597
|
+
return _banner_frame("".join(out), pal, title)
|
|
1598
|
+
|
|
1599
|
+
|
|
1600
|
+
def banner_starfield(seed: int, pal: List[str], title: str = "") -> str:
|
|
1601
|
+
"""Constellations over layered ridges — the night-sky read."""
|
|
1602
|
+
nxt = _banner_lcg(seed)
|
|
1603
|
+
w, h = SVG_WIDTH, SVG_HEIGHT
|
|
1604
|
+
horizon = int(h * 0.72)
|
|
1605
|
+
out: List[str] = []
|
|
1606
|
+
|
|
1607
|
+
for _ in range(230):
|
|
1608
|
+
x, y = nxt(w), nxt(horizon)
|
|
1609
|
+
# Thin the field toward the horizon so the sky reads as deep.
|
|
1610
|
+
if nxt(horizon) < y * 0.75:
|
|
1323
1611
|
continue
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
for
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1612
|
+
out.append(f'<circle cx="{x}" cy="{y}" r="{1 + nxt(3)}" fill="{pal[5]}" '
|
|
1613
|
+
f'fill-opacity="{_num(0.3 + nxt(70) / 100.0, 2)}" />')
|
|
1614
|
+
|
|
1615
|
+
for _ in range(4):
|
|
1616
|
+
cx, cy = 120 + nxt(w - 240), 90 + nxt(horizon - 220)
|
|
1617
|
+
nodes = [(cx, cy)]
|
|
1618
|
+
for _ in range(3 + nxt(3)):
|
|
1619
|
+
cx += nxt(220) - 90
|
|
1620
|
+
cy += nxt(180) - 80
|
|
1621
|
+
nodes.append((cx, cy))
|
|
1622
|
+
d = "M" + " L".join(f"{nx} {ny}" for nx, ny in nodes)
|
|
1623
|
+
out.append(f'<path d="{d}" fill="none" stroke="{pal[4]}" '
|
|
1624
|
+
f'stroke-width="2" stroke-opacity="0.55" />')
|
|
1625
|
+
for (nx, ny) in nodes:
|
|
1626
|
+
out.append(f'<circle cx="{nx}" cy="{ny}" r="5" fill="{pal[4]}" />')
|
|
1627
|
+
|
|
1628
|
+
mx, my = w - 260 - nxt(200), 150 + nxt(120)
|
|
1629
|
+
out.append(f'<circle cx="{mx}" cy="{my}" r="86" fill="{pal[3]}" '
|
|
1630
|
+
f'fill-opacity="0.18" />')
|
|
1631
|
+
out.append(f'<circle cx="{mx}" cy="{my}" r="62" fill="{pal[5]}" />')
|
|
1632
|
+
out.append(f'<circle cx="{mx + 26}" cy="{my - 16}" r="56" fill="{pal[0]}" />')
|
|
1633
|
+
|
|
1634
|
+
# Aerial perspective: the FARTHEST ridge is hazy and light, each nearer one
|
|
1635
|
+
# steps darker. Every layer closes to the bottom of the frame, so the
|
|
1636
|
+
# foreground is ridge mass rather than a flat band of background.
|
|
1637
|
+
for layer in range(4):
|
|
1638
|
+
top = horizon - 40 + layer * 58
|
|
1639
|
+
points = [f"0,{h}"]
|
|
1640
|
+
x, y = 0, float(top)
|
|
1641
|
+
while x <= w:
|
|
1642
|
+
points.append(f"{x},{_num(y)}")
|
|
1643
|
+
x += 40 + nxt(80)
|
|
1644
|
+
y = top + nxt(56) - 24
|
|
1645
|
+
points.append(f"{w},{_num(float(top))}")
|
|
1646
|
+
points.append(f"{w},{h}")
|
|
1647
|
+
out.append(f'<polygon points="{" ".join(points)}" '
|
|
1648
|
+
f'fill="{_mix(pal[3], pal[0], layer / 3.0)}" />')
|
|
1649
|
+
return _banner_frame("".join(out), pal, title)
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
def banner_pixelscene(seed: int, pal: List[str], title: str = "") -> str:
|
|
1653
|
+
"""Pixel-art side-scroller on a strict integer grid, run-length merged."""
|
|
1654
|
+
nxt = _banner_lcg(seed)
|
|
1655
|
+
px = 16
|
|
1656
|
+
cols, rows = SVG_WIDTH // px, SVG_HEIGHT // px
|
|
1657
|
+
cells: List[List[Optional[str]]] = [[None] * cols for _ in range(rows)]
|
|
1658
|
+
|
|
1659
|
+
def put(cx: int, cy: int, color: str) -> None:
|
|
1660
|
+
if 0 <= cx < cols and 0 <= cy < rows:
|
|
1661
|
+
cells[cy][cx] = color
|
|
1662
|
+
|
|
1663
|
+
def block(cx: int, cy: int, bw: int, bh: int, color: str) -> None:
|
|
1664
|
+
for yy in range(cy, cy + bh):
|
|
1665
|
+
for xx in range(cx, cx + bw):
|
|
1666
|
+
put(xx, yy, color)
|
|
1667
|
+
|
|
1668
|
+
ground = rows - 12
|
|
1669
|
+
for _ in range(150):
|
|
1670
|
+
sx, sy = nxt(cols), nxt(ground - 4)
|
|
1671
|
+
if nxt(ground) > sy:
|
|
1672
|
+
put(sx, sy, pal[5] if nxt(4) else pal[3])
|
|
1673
|
+
|
|
1674
|
+
sun_x, sun_y = cols - 20 + nxt(8), 6 + nxt(5)
|
|
1675
|
+
for yy in range(-4, 5):
|
|
1676
|
+
for xx in range(-4, 5):
|
|
1677
|
+
d2 = xx * xx + yy * yy
|
|
1678
|
+
if d2 <= 16:
|
|
1679
|
+
put(sun_x + xx, sun_y + yy, pal[4])
|
|
1680
|
+
elif d2 <= 25:
|
|
1681
|
+
put(sun_x + xx, sun_y + yy, pal[3])
|
|
1682
|
+
|
|
1683
|
+
# `surface` is the row the ground starts at; fill from there to the bottom
|
|
1684
|
+
# so the land is solid mass rather than a one-cell scribble.
|
|
1685
|
+
surface = ground
|
|
1686
|
+
for cx in range(cols):
|
|
1687
|
+
if nxt(4) == 0:
|
|
1688
|
+
surface = max(ground - 5, min(rows - 4, surface + nxt(3) - 1))
|
|
1689
|
+
block(cx, surface, 1, rows - surface, pal[2])
|
|
1690
|
+
put(cx, surface, pal[3])
|
|
1691
|
+
if nxt(7) == 0:
|
|
1692
|
+
put(cx, surface + 2 + nxt(4), pal[1])
|
|
1693
|
+
|
|
1694
|
+
for _ in range(5):
|
|
1695
|
+
span = 5 + nxt(8)
|
|
1696
|
+
bx, by = nxt(max(cols - span, 1)), ground - 9 - nxt(16)
|
|
1697
|
+
for i in range(span):
|
|
1698
|
+
put(bx + i, by, pal[3])
|
|
1699
|
+
put(bx + i, by + 1, pal[1])
|
|
1700
|
+
|
|
1701
|
+
# The hero: 6x9 cells with a dark keyline, so it reads off any ground
|
|
1702
|
+
# colour and stays the only glow-coloured figure in frame.
|
|
1703
|
+
hx, hy = 10 + nxt(14), ground - 10
|
|
1704
|
+
block(hx - 1, hy - 1, 8, 11, pal[0])
|
|
1705
|
+
block(hx + 1, hy, 4, 2, pal[4])
|
|
1706
|
+
block(hx + 1, hy + 2, 4, 1, pal[1])
|
|
1707
|
+
block(hx, hy + 3, 6, 4, pal[5])
|
|
1708
|
+
block(hx + 2, hy + 4, 2, 2, pal[3])
|
|
1709
|
+
block(hx, hy + 7, 2, 3, pal[5])
|
|
1710
|
+
block(hx + 4, hy + 7, 2, 3, pal[5])
|
|
1711
|
+
block(hx + 6, hy + 1, 1, 6, pal[4])
|
|
1712
|
+
|
|
1713
|
+
# Merge horizontal runs of one colour into single rects: a 96x64 scene
|
|
1714
|
+
# stays a few KB instead of six thousand one-cell rects.
|
|
1715
|
+
out: List[str] = []
|
|
1716
|
+
for y in range(rows):
|
|
1717
|
+
x = 0
|
|
1718
|
+
while x < cols:
|
|
1719
|
+
color = cells[y][x]
|
|
1720
|
+
if color is None:
|
|
1721
|
+
x += 1
|
|
1722
|
+
continue
|
|
1723
|
+
run = 1
|
|
1724
|
+
while x + run < cols and cells[y][x + run] == color:
|
|
1725
|
+
run += 1
|
|
1726
|
+
out.append(f'<rect x="{x * px}" y="{y * px}" width="{run * px}" '
|
|
1727
|
+
f'height="{px}" fill="{color}" />')
|
|
1728
|
+
x += run
|
|
1729
|
+
body = f'<g shape-rendering="crispEdges">{"".join(out)}</g>'
|
|
1730
|
+
veil = "".join(f'<rect y="{y}" width="{SVG_WIDTH}" height="2" />'
|
|
1731
|
+
for y in range(0, SVG_HEIGHT, 6))
|
|
1732
|
+
body += f'<g fill="{pal[0]}" fill-opacity="0.22">{veil}</g>'
|
|
1733
|
+
return _banner_frame(body, pal, title)
|
|
1734
|
+
|
|
1735
|
+
|
|
1736
|
+
BANNER_COMPOSITIONS = {
|
|
1737
|
+
"circuit": banner_circuit,
|
|
1738
|
+
"flowfield": banner_flowfield,
|
|
1739
|
+
"lowpoly": banner_lowpoly,
|
|
1740
|
+
"pixelscene": banner_pixelscene,
|
|
1741
|
+
"starfield": banner_starfield,
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
|
|
1745
|
+
_BANNER_WORD = re.compile(r"[a-z0-9]+")
|
|
1746
|
+
|
|
1747
|
+
|
|
1748
|
+
def _banner_words(text: str) -> List[str]:
|
|
1749
|
+
"""Whole words, lowercased. Substring matching is not good enough here:
|
|
1750
|
+
a bare `"ci" in text` fires on 'decision', 'specific' and 'efficiency',
|
|
1751
|
+
which is enough to drag every article on a dev blog toward one look."""
|
|
1752
|
+
return _BANNER_WORD.findall(str(text or "").lower())
|
|
1753
|
+
|
|
1754
|
+
|
|
1755
|
+
def banner_signature(article: Dict[str, str]) -> str:
|
|
1756
|
+
"""The text the art is derived from. Title and tags carry the topic; the
|
|
1757
|
+
section carries the editorial intent; a slice of the body keeps two posts
|
|
1758
|
+
with near-identical titles from colliding."""
|
|
1759
|
+
return " ".join([
|
|
1760
|
+
article.get("section", ""),
|
|
1761
|
+
article.get("categories", ""),
|
|
1762
|
+
article.get("title", ""),
|
|
1763
|
+
article.get("description", ""),
|
|
1764
|
+
article.get("body", "")[:600],
|
|
1765
|
+
]).lower()
|
|
1766
|
+
|
|
1767
|
+
|
|
1768
|
+
def banner_seed_for(article: Dict[str, str]) -> int:
|
|
1769
|
+
"""Seed from the article's CONTENT, not just its filename — so retitling or
|
|
1770
|
+
re-tagging a piece re-rolls its art, and two posts that merely sort next to
|
|
1771
|
+
each other don't come out kin."""
|
|
1772
|
+
key = banner_signature(article) + "|" + article.get("slug", "")
|
|
1773
|
+
return zlib.crc32(key.encode("utf-8"))
|
|
1774
|
+
|
|
1775
|
+
|
|
1776
|
+
def banner_theme_for(article: Dict[str, str]) -> Tuple[str, str]:
|
|
1777
|
+
"""(composition, palette) for an article. Keyword hits decide; the seed only
|
|
1778
|
+
breaks ties and picks the palette, so the mapping is legible to a human
|
|
1779
|
+
reading the config rather than an opaque hash.
|
|
1780
|
+
|
|
1781
|
+
The fields are weighted, not pooled. A section name is a deliberate
|
|
1782
|
+
editorial choice and the tags and title are hand-written, so they carry
|
|
1783
|
+
most of the signal; the body is long, incidental, and full of words the
|
|
1784
|
+
author never chose, so each of its hits counts once and the whole field is
|
|
1785
|
+
capped. Pooling them all equally lets a 3,000-word article's stray
|
|
1786
|
+
vocabulary outvote its own title."""
|
|
1787
|
+
fields = (
|
|
1788
|
+
(article.get("section", ""), 6),
|
|
1789
|
+
(article.get("categories", ""), 3),
|
|
1790
|
+
(article.get("title", ""), 3),
|
|
1791
|
+
(article.get("description", ""), 2),
|
|
1792
|
+
(article.get("body", "")[:4000], 1),
|
|
1793
|
+
)
|
|
1794
|
+
scored = [(set(_banner_words(text)), weight) for text, weight in fields]
|
|
1795
|
+
seed = banner_seed_for(article)
|
|
1796
|
+
|
|
1797
|
+
best_name, best_score = "", 0
|
|
1798
|
+
for name, keywords in BANNER_KEYWORDS:
|
|
1799
|
+
wanted = set(keywords)
|
|
1800
|
+
score = 0
|
|
1801
|
+
for words, weight in scored:
|
|
1802
|
+
hits = len(wanted & words)
|
|
1803
|
+
# Cap the body's contribution: presence, not word count, is what
|
|
1804
|
+
# the low-weight field is evidence of.
|
|
1805
|
+
score += min(hits, 4) * weight if weight == 1 else hits * weight
|
|
1806
|
+
# Strictly greater, so ties fall to the earlier (more specific) entry.
|
|
1807
|
+
if score > best_score:
|
|
1808
|
+
best_name, best_score = name, score
|
|
1809
|
+
if not best_name:
|
|
1810
|
+
best_name = BANNER_COMPOSITION_ORDER[seed % len(BANNER_COMPOSITION_ORDER)]
|
|
1811
|
+
|
|
1812
|
+
palette = BANNER_PALETTE_ORDER[(seed >> 8) % len(BANNER_PALETTE_ORDER)]
|
|
1813
|
+
return best_name, palette
|
|
1814
|
+
|
|
1815
|
+
|
|
1816
|
+
def render_local_svg(title: str, seed: int,
|
|
1817
|
+
article: Optional[Dict[str, str]] = None) -> str:
|
|
1818
|
+
"""Deterministic banner for the `local` provider (no network).
|
|
1819
|
+
|
|
1820
|
+
`article` carries the page's context (slug, section, title, tags, body); it
|
|
1821
|
+
selects the composition and palette. Without it — a caller that only has a
|
|
1822
|
+
slug — the art still renders, just chosen from the seed alone."""
|
|
1823
|
+
if article:
|
|
1824
|
+
composition, palette = banner_theme_for(article)
|
|
1825
|
+
art_seed = banner_seed_for(article)
|
|
1344
1826
|
else:
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
for y in range(0, h, 8):
|
|
1351
|
-
parts.append(f'<rect y="{y}" width="{w}" height="1" fill="#000" opacity="0.10"/>')
|
|
1352
|
-
parts.append(f'<rect width="{w}" height="26" fill="#000" opacity="0.35"/>')
|
|
1353
|
-
parts.append(f'<rect y="{h - 26}" width="{w}" height="26" fill="#000" opacity="0.35"/>')
|
|
1354
|
-
parts.append("</svg>")
|
|
1355
|
-
return "".join(parts)
|
|
1827
|
+
composition = BANNER_COMPOSITION_ORDER[seed % len(BANNER_COMPOSITION_ORDER)]
|
|
1828
|
+
palette = BANNER_PALETTE_ORDER[(seed >> 8) % len(BANNER_PALETTE_ORDER)]
|
|
1829
|
+
art_seed = seed
|
|
1830
|
+
return BANNER_COMPOSITIONS[composition](
|
|
1831
|
+
art_seed, BANNER_PALETTES[palette], title)
|
|
1356
1832
|
|
|
1357
1833
|
|
|
1358
1834
|
# =============================================================================
|
|
@@ -1708,6 +2184,21 @@ class Provider:
|
|
|
1708
2184
|
def default_model(self) -> str:
|
|
1709
2185
|
raise NotImplementedError
|
|
1710
2186
|
|
|
2187
|
+
def auth_description(self, env: Dict[str, str]) -> Optional[str]:
|
|
2188
|
+
"""One line naming the credential this run will authenticate with, for
|
|
2189
|
+
the config banner. None (the default) means "there is nothing
|
|
2190
|
+
interesting to say" — a provider with exactly one possible credential."""
|
|
2191
|
+
return None
|
|
2192
|
+
|
|
2193
|
+
def output_stem(self, slug: str, ctx: "RunContext") -> str:
|
|
2194
|
+
"""Filename stem this provider writes to, minus the extension.
|
|
2195
|
+
|
|
2196
|
+
Per-article by default. A provider that deliberately SHARES one image
|
|
2197
|
+
across many pages (the collection default) overrides this so the runner
|
|
2198
|
+
writes one file and stamps every page at it, instead of emitting N
|
|
2199
|
+
near-identical copies under N names."""
|
|
2200
|
+
return slug
|
|
2201
|
+
|
|
1711
2202
|
def generate(self, prompt: str, settings: Settings, out_base: Path,
|
|
1712
2203
|
ctx: "RunContext") -> ImageResult:
|
|
1713
2204
|
raise NotImplementedError
|
|
@@ -1727,6 +2218,11 @@ class RunContext:
|
|
|
1727
2218
|
env: Dict[str, str]
|
|
1728
2219
|
anthropic: Optional[AnthropicClient] = None
|
|
1729
2220
|
slug: str = ""
|
|
2221
|
+
# The page's own context (slug/section/title/categories/description/body),
|
|
2222
|
+
# set per file by the runner. The local renderer derives BOTH composition
|
|
2223
|
+
# and palette from it, which is what makes a banner about its article
|
|
2224
|
+
# instead of about its filename.
|
|
2225
|
+
article: Optional[Dict[str, str]] = None
|
|
1730
2226
|
|
|
1731
2227
|
def claude(self) -> AnthropicClient:
|
|
1732
2228
|
if self.anthropic is None:
|
|
@@ -1835,40 +2331,319 @@ class OpenAIProvider(Provider):
|
|
|
1835
2331
|
return ImageResult(False, error=str(exc))
|
|
1836
2332
|
|
|
1837
2333
|
|
|
2334
|
+
# --- xAI (Grok) credentials --------------------------------------------------
|
|
2335
|
+
#
|
|
2336
|
+
# api.x.ai takes a plain `Authorization: Bearer <value>` for BOTH kinds of
|
|
2337
|
+
# credential, so the only thing that varies between a Grok OAuth access token
|
|
2338
|
+
# and a console API key is where the value comes from and what we tell the human
|
|
2339
|
+
# when it is rejected.
|
|
2340
|
+
#
|
|
2341
|
+
# Deliberately NOT implemented: minting or refreshing an OAuth token. xAI does
|
|
2342
|
+
# not publish its device-code/PKCE endpoints or a public client id, and the
|
|
2343
|
+
# credentials file belongs to whichever login tool wrote it — inventing a token
|
|
2344
|
+
# endpoint, or writing back into another tool's store, would be guesswork. The
|
|
2345
|
+
# engine CONSUMES a token that a Grok login already produced, treats an expired
|
|
2346
|
+
# one as absent, and falls back to the API key.
|
|
2347
|
+
XAI_API_BASE = "https://api.x.ai/v1"
|
|
2348
|
+
|
|
2349
|
+
# OAuth access token straight from the environment. XAI_ first (it matches the
|
|
2350
|
+
# vendor's own XAI_* naming), GROK_ accepted because that is what the
|
|
2351
|
+
# consumer-facing login is called.
|
|
2352
|
+
XAI_OAUTH_TOKEN_ENV: Tuple[str, ...] = ("XAI_OAUTH_TOKEN", "GROK_OAUTH_TOKEN")
|
|
2353
|
+
|
|
2354
|
+
# Explicit path to a credentials file written by a Grok OAuth login.
|
|
2355
|
+
XAI_CREDENTIALS_FILE_ENV: Tuple[str, ...] = (
|
|
2356
|
+
"XAI_OAUTH_CREDENTIALS", "GROK_OAUTH_CREDENTIALS",
|
|
2357
|
+
)
|
|
2358
|
+
|
|
2359
|
+
XAI_API_KEY_ENV = "XAI_API_KEY"
|
|
2360
|
+
|
|
2361
|
+
# Where a Grok login drops its token when no path is configured. Relative to the
|
|
2362
|
+
# home directory so a test (and a container) can point HOME elsewhere.
|
|
2363
|
+
XAI_DEFAULT_CREDENTIALS_FILES: Tuple[str, ...] = (
|
|
2364
|
+
".grok/auth.json", ".config/grok/auth.json", ".xai/auth.json",
|
|
2365
|
+
)
|
|
2366
|
+
|
|
2367
|
+
# Credential files nest the token differently per tool; look at the top level
|
|
2368
|
+
# first, then inside the usual containers.
|
|
2369
|
+
XAI_NESTED_KEYS: Tuple[str, ...] = ("xai", "grok", "oauth", "tokens", "credentials")
|
|
2370
|
+
|
|
2371
|
+
# Treat a token that expires within the minute as already gone — a run can
|
|
2372
|
+
# easily outlive that, and the API key fallback is cheaper than a mid-run 401 on
|
|
2373
|
+
# every remaining page.
|
|
2374
|
+
XAI_EXPIRY_LEEWAY = 60.0
|
|
2375
|
+
|
|
2376
|
+
# Which rung(s) to try, as an env knob. `auto` (the default) is OAuth first with
|
|
2377
|
+
# the API key as the fallback; the other two pin one rung.
|
|
2378
|
+
XAI_AUTH_MODE_ENV = "XAI_AUTH"
|
|
2379
|
+
XAI_AUTH_MODES: Tuple[str, ...] = ("auto", "oauth", "api_key")
|
|
2380
|
+
|
|
2381
|
+
XAI_MISSING_HINT = (
|
|
2382
|
+
"The xAI provider needs a Grok OAuth token or an xAI API key — any ONE of:\n"
|
|
2383
|
+
" 1. XAI_OAUTH_TOKEN (or GROK_OAUTH_TOKEN) — an OAuth access token from a\n"
|
|
2384
|
+
" Grok login (SuperGrok / X Premium+)\n"
|
|
2385
|
+
" 2. a Grok OAuth credentials file — ~/.grok/auth.json by default, or the\n"
|
|
2386
|
+
" path in XAI_OAUTH_CREDENTIALS / GROK_OAUTH_CREDENTIALS\n"
|
|
2387
|
+
" 3. XAI_API_KEY — a key from console.x.ai (also the automatic fallback\n"
|
|
2388
|
+
" when an OAuth token is expired or rejected)\n"
|
|
2389
|
+
"Pin one rung with XAI_AUTH=oauth|api_key; the default tries OAuth first."
|
|
2390
|
+
)
|
|
2391
|
+
|
|
2392
|
+
|
|
2393
|
+
@dataclass
|
|
2394
|
+
class XAICredential:
|
|
2395
|
+
"""One resolved way to authenticate against api.x.ai."""
|
|
2396
|
+
mode: str # "oauth" | "api_key" — which rung of the chain this is
|
|
2397
|
+
token: str
|
|
2398
|
+
source: str # human label for logs and fallback warnings
|
|
2399
|
+
|
|
2400
|
+
def headers(self) -> Dict[str, str]:
|
|
2401
|
+
return {"Authorization": f"Bearer {self.token}"}
|
|
2402
|
+
|
|
2403
|
+
|
|
2404
|
+
def xai_auth_mode(env: Dict[str, str]) -> str:
|
|
2405
|
+
raw = (env.get(XAI_AUTH_MODE_ENV) or "").strip().lower()
|
|
2406
|
+
if not raw:
|
|
2407
|
+
return "auto"
|
|
2408
|
+
if raw in XAI_AUTH_MODES:
|
|
2409
|
+
return raw
|
|
2410
|
+
debug(f"{XAI_AUTH_MODE_ENV}={raw} is not one of "
|
|
2411
|
+
f"{'|'.join(XAI_AUTH_MODES)}; using auto")
|
|
2412
|
+
return "auto"
|
|
2413
|
+
|
|
2414
|
+
|
|
2415
|
+
def xai_credential_chain(env: Dict[str, str]) -> List[XAICredential]:
|
|
2416
|
+
"""The credentials to try, best rung first. Empty = provider unconfigured."""
|
|
2417
|
+
mode = xai_auth_mode(env)
|
|
2418
|
+
chain: List[XAICredential] = []
|
|
2419
|
+
if mode != "api_key":
|
|
2420
|
+
chain.extend(_xai_oauth_credentials(env))
|
|
2421
|
+
if mode != "oauth":
|
|
2422
|
+
api_key = _xai_api_key_credential(env)
|
|
2423
|
+
if api_key is not None:
|
|
2424
|
+
chain.append(api_key)
|
|
2425
|
+
return chain
|
|
2426
|
+
|
|
2427
|
+
|
|
2428
|
+
def xai_auth_describe(env: Dict[str, str]) -> Optional[str]:
|
|
2429
|
+
"""One-line summary of how this run will authenticate (None when it cannot)."""
|
|
2430
|
+
chain = xai_credential_chain(env)
|
|
2431
|
+
if not chain:
|
|
2432
|
+
return None
|
|
2433
|
+
if len(chain) > 1:
|
|
2434
|
+
return f"{chain[0].source}, falling back to {chain[1].source}"
|
|
2435
|
+
return chain[0].source
|
|
2436
|
+
|
|
2437
|
+
|
|
2438
|
+
def xai_base_url(env: Dict[str, str]) -> str:
|
|
2439
|
+
"""Base for the images endpoint. XAI_BASE_URL is xAI's own override and is
|
|
2440
|
+
honored here so an OAuth surface on a different host stays reachable."""
|
|
2441
|
+
configured = env.get("XAI_BASE_URL") or ""
|
|
2442
|
+
return configured.rstrip("/") if configured else XAI_API_BASE
|
|
2443
|
+
|
|
2444
|
+
|
|
2445
|
+
def _xai_oauth_credentials(env: Dict[str, str]) -> List[XAICredential]:
|
|
2446
|
+
for name in XAI_OAUTH_TOKEN_ENV:
|
|
2447
|
+
value = env.get(name)
|
|
2448
|
+
if value:
|
|
2449
|
+
return [XAICredential("oauth", value, f"{name} (Grok OAuth token)")]
|
|
2450
|
+
for path in _xai_credential_files(env):
|
|
2451
|
+
credential = _xai_file_credential(path)
|
|
2452
|
+
if credential is not None:
|
|
2453
|
+
return [credential]
|
|
2454
|
+
return []
|
|
2455
|
+
|
|
2456
|
+
|
|
2457
|
+
def _xai_api_key_credential(env: Dict[str, str]) -> Optional[XAICredential]:
|
|
2458
|
+
value = env.get(XAI_API_KEY_ENV)
|
|
2459
|
+
if not value:
|
|
2460
|
+
return None
|
|
2461
|
+
return XAICredential("api_key", value, f"{XAI_API_KEY_ENV} (xAI API key)")
|
|
2462
|
+
|
|
2463
|
+
|
|
2464
|
+
def _xai_credential_files(env: Dict[str, str]) -> List[str]:
|
|
2465
|
+
"""Candidate credential-file paths: an explicit override wins outright, so a
|
|
2466
|
+
configured-but-missing path never silently reads someone else's token."""
|
|
2467
|
+
for name in XAI_CREDENTIALS_FILE_ENV:
|
|
2468
|
+
value = env.get(name)
|
|
2469
|
+
if value:
|
|
2470
|
+
return [value]
|
|
2471
|
+
home = _xai_home_dir(env)
|
|
2472
|
+
if not home:
|
|
2473
|
+
return []
|
|
2474
|
+
return [os.path.join(home, rel) for rel in XAI_DEFAULT_CREDENTIALS_FILES]
|
|
2475
|
+
|
|
2476
|
+
|
|
2477
|
+
def _xai_home_dir(env: Dict[str, str]) -> Optional[str]:
|
|
2478
|
+
"""The env snapshot is the source of truth (tests and containers set HOME);
|
|
2479
|
+
the process's own home is only the last resort."""
|
|
2480
|
+
for name in ("HOME", "USERPROFILE"):
|
|
2481
|
+
value = env.get(name)
|
|
2482
|
+
if value:
|
|
2483
|
+
return value
|
|
2484
|
+
home = os.path.expanduser("~")
|
|
2485
|
+
# expanduser returns "~" unchanged when it cannot resolve a home directory.
|
|
2486
|
+
return home if home and home != "~" else None
|
|
2487
|
+
|
|
2488
|
+
|
|
2489
|
+
def _xai_file_credential(path: str) -> Optional[XAICredential]:
|
|
2490
|
+
"""Read one credentials file. Anything unreadable, unparseable, tokenless or
|
|
2491
|
+
expired is "no credential here" — the chain moves on to the next rung."""
|
|
2492
|
+
if not os.path.isfile(path):
|
|
2493
|
+
return None
|
|
2494
|
+
try:
|
|
2495
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
2496
|
+
except json.JSONDecodeError as exc:
|
|
2497
|
+
debug(f"xAI OAuth: {path} is not valid JSON ({exc}); ignoring")
|
|
2498
|
+
return None
|
|
2499
|
+
except OSError as exc:
|
|
2500
|
+
debug(f"xAI OAuth: cannot read {path} ({exc}); ignoring")
|
|
2501
|
+
return None
|
|
2502
|
+
if not isinstance(data, dict):
|
|
2503
|
+
debug(f"xAI OAuth: {path} is not a JSON object; ignoring")
|
|
2504
|
+
return None
|
|
2505
|
+
node = _xai_token_node(data)
|
|
2506
|
+
if node is None:
|
|
2507
|
+
debug(f"xAI OAuth: no access_token in {path}; ignoring")
|
|
2508
|
+
return None
|
|
2509
|
+
if _xai_expired(node):
|
|
2510
|
+
debug(f"xAI OAuth: the token in {path} has expired; re-run your Grok login")
|
|
2511
|
+
return None
|
|
2512
|
+
return XAICredential("oauth", str(node["access_token"]),
|
|
2513
|
+
f"Grok OAuth token ({path})")
|
|
2514
|
+
|
|
2515
|
+
|
|
2516
|
+
def _xai_token_node(data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
2517
|
+
"""The dict actually holding `access_token`: the document itself, or one of
|
|
2518
|
+
the containers a login tool nests it under."""
|
|
2519
|
+
if _xai_token_string(data.get("access_token")):
|
|
2520
|
+
return data
|
|
2521
|
+
for key in XAI_NESTED_KEYS:
|
|
2522
|
+
nested = data.get(key)
|
|
2523
|
+
if isinstance(nested, dict) and _xai_token_string(nested.get("access_token")):
|
|
2524
|
+
return nested
|
|
2525
|
+
return None
|
|
2526
|
+
|
|
2527
|
+
|
|
2528
|
+
def _xai_token_string(value: Any) -> bool:
|
|
2529
|
+
return isinstance(value, str) and bool(value)
|
|
2530
|
+
|
|
2531
|
+
|
|
2532
|
+
def _xai_expired(node: Dict[str, Any], now: Optional[float] = None) -> bool:
|
|
2533
|
+
expires_at = _xai_absolute_expiry(node)
|
|
2534
|
+
if expires_at is None:
|
|
2535
|
+
return False
|
|
2536
|
+
return expires_at <= (time.time() if now is None else now) + XAI_EXPIRY_LEEWAY
|
|
2537
|
+
|
|
2538
|
+
|
|
2539
|
+
def _xai_absolute_expiry(node: Dict[str, Any]) -> Optional[float]:
|
|
2540
|
+
"""Absolute expiry in epoch seconds, or None when the file records none."""
|
|
2541
|
+
for key in ("expires_at", "expiry", "expiration", "expires"):
|
|
2542
|
+
value = _xai_epoch_seconds(node.get(key))
|
|
2543
|
+
if value is not None:
|
|
2544
|
+
return value
|
|
2545
|
+
# `expires_in` is relative and only means something next to an issue time.
|
|
2546
|
+
lifetime = _xai_number(node.get("expires_in"))
|
|
2547
|
+
if lifetime is None:
|
|
2548
|
+
return None
|
|
2549
|
+
for key in ("created_at", "obtained_at", "issued_at", "issued"):
|
|
2550
|
+
issued = _xai_epoch_seconds(node.get(key))
|
|
2551
|
+
if issued is not None:
|
|
2552
|
+
return issued + lifetime
|
|
2553
|
+
return None
|
|
2554
|
+
|
|
2555
|
+
|
|
2556
|
+
def _xai_epoch_seconds(raw: Any) -> Optional[float]:
|
|
2557
|
+
"""Accepts epoch seconds, epoch milliseconds, or an ISO-8601 timestamp."""
|
|
2558
|
+
value = _xai_number(raw)
|
|
2559
|
+
if value is not None:
|
|
2560
|
+
# 1e11 seconds is the year 5138; anything past it is milliseconds.
|
|
2561
|
+
return value / 1000.0 if value > 1e11 else value
|
|
2562
|
+
if not isinstance(raw, str) or not raw:
|
|
2563
|
+
return None
|
|
2564
|
+
text = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
|
|
2565
|
+
try:
|
|
2566
|
+
parsed = datetime.fromisoformat(text)
|
|
2567
|
+
except ValueError:
|
|
2568
|
+
return None
|
|
2569
|
+
if parsed.tzinfo is None:
|
|
2570
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
2571
|
+
return parsed.timestamp()
|
|
2572
|
+
|
|
2573
|
+
|
|
2574
|
+
def _xai_number(raw: Any) -> Optional[float]:
|
|
2575
|
+
# bool is an int subclass; a JSON true/false is not a timestamp.
|
|
2576
|
+
if isinstance(raw, bool):
|
|
2577
|
+
return None
|
|
2578
|
+
if isinstance(raw, (int, float)):
|
|
2579
|
+
return float(raw)
|
|
2580
|
+
if not isinstance(raw, str) or not raw:
|
|
2581
|
+
return None
|
|
2582
|
+
try:
|
|
2583
|
+
return float(raw)
|
|
2584
|
+
except ValueError:
|
|
2585
|
+
return None
|
|
2586
|
+
|
|
2587
|
+
|
|
1838
2588
|
class XAIProvider(Provider):
|
|
2589
|
+
"""xAI (Grok) images/generations.
|
|
2590
|
+
|
|
2591
|
+
Two ways in, tried in order: a Grok OAuth access token, then the
|
|
2592
|
+
XAI_API_KEY. Both are Bearer credentials on the same endpoint, so the
|
|
2593
|
+
fallback is genuinely a retry with a different token — an OAuth token that
|
|
2594
|
+
has gone stale between runs costs one rejected request and then rides the
|
|
2595
|
+
API key instead of failing the page."""
|
|
1839
2596
|
name = "xai"
|
|
1840
2597
|
|
|
2598
|
+
# Statuses that mean "this credential is no good" rather than "this request
|
|
2599
|
+
# is no good" — the only ones worth re-trying on the next rung.
|
|
2600
|
+
AUTH_FAILURE_STATUSES = (401, 403)
|
|
2601
|
+
|
|
2602
|
+
# xAI caps the prompt; the original engine slices the first 1000 chars.
|
|
2603
|
+
PROMPT_LIMIT = 1000
|
|
2604
|
+
|
|
1841
2605
|
def is_configured(self, env: Dict[str, str]) -> bool:
|
|
1842
|
-
return bool(env
|
|
2606
|
+
return bool(xai_credential_chain(env))
|
|
1843
2607
|
|
|
1844
2608
|
def missing_hint(self, env: Dict[str, str]) -> str:
|
|
1845
|
-
return
|
|
2609
|
+
return XAI_MISSING_HINT
|
|
2610
|
+
|
|
2611
|
+
def auth_description(self, env: Dict[str, str]) -> Optional[str]:
|
|
2612
|
+
return xai_auth_describe(env)
|
|
1846
2613
|
|
|
1847
2614
|
def default_model(self) -> str:
|
|
1848
2615
|
return "grok-2-image"
|
|
1849
2616
|
|
|
1850
2617
|
def generate(self, prompt, settings, out_base, ctx) -> ImageResult:
|
|
2618
|
+
chain = xai_credential_chain(ctx.env)
|
|
2619
|
+
if not chain:
|
|
2620
|
+
return ImageResult(False, error=XAI_MISSING_HINT)
|
|
1851
2621
|
model = effective_model(settings, self)
|
|
1852
2622
|
out_path = out_base.with_suffix(".png")
|
|
1853
|
-
payload = {"model": model, "prompt": prompt[:
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
)
|
|
1862
|
-
"
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
2623
|
+
payload = {"model": model, "prompt": prompt[:self.PROMPT_LIMIT], "n": 1}
|
|
2624
|
+
url = f"{xai_base_url(ctx.env)}/images/generations"
|
|
2625
|
+
for index, credential in enumerate(chain):
|
|
2626
|
+
debug(f"xAI generate: model={model} auth={credential.source}")
|
|
2627
|
+
try:
|
|
2628
|
+
data = with_retries(
|
|
2629
|
+
lambda: http_json(url, payload, credential.headers(), timeout=900),
|
|
2630
|
+
"xAI API",
|
|
2631
|
+
)
|
|
2632
|
+
entries = data.get("data") or []
|
|
2633
|
+
if not entries or not _write_image_payload(entries[0], out_path):
|
|
2634
|
+
return ImageResult(False, error="No image data in xAI response")
|
|
2635
|
+
return ImageResult(True, "png", out_path)
|
|
2636
|
+
except HttpStatusError as exc:
|
|
2637
|
+
fallback = chain[index + 1] if index + 1 < len(chain) else None
|
|
2638
|
+
if exc.status in self.AUTH_FAILURE_STATUSES and fallback is not None:
|
|
2639
|
+
warn(f"xAI rejected the {credential.source} (HTTP {exc.status}) — "
|
|
2640
|
+
f"retrying with {fallback.source}")
|
|
2641
|
+
continue
|
|
2642
|
+
return ImageResult(False, error=f"xAI API error: {exc.message()}")
|
|
2643
|
+
except Exception as exc:
|
|
2644
|
+
return ImageResult(False, error=str(exc))
|
|
2645
|
+
# Unreachable: the last rung has no fallback and always returns above.
|
|
2646
|
+
return ImageResult(False, error="No usable xAI credential")
|
|
1872
2647
|
|
|
1873
2648
|
|
|
1874
2649
|
class StabilityProvider(Provider):
|
|
@@ -1977,11 +2752,17 @@ class SvgProviderMixin:
|
|
|
1977
2752
|
if tool:
|
|
1978
2753
|
svg_path.unlink(missing_ok=True)
|
|
1979
2754
|
return ImageResult(True, "png", png_path)
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
"
|
|
1984
|
-
|
|
2755
|
+
# `rasterizer: none` is a deliberate site policy (SVG-only), not a
|
|
2756
|
+
# missing dependency — say so instead of nagging about librsvg.
|
|
2757
|
+
if settings.rasterizer == "none":
|
|
2758
|
+
info(f"SVG-only mode (rasterizer: none) — wrote {svg_path.name}")
|
|
2759
|
+
else:
|
|
2760
|
+
warn(
|
|
2761
|
+
"No SVG rasterizer available — keeping the .svg preview. Social "
|
|
2762
|
+
"og:image works best as PNG: install librsvg (`brew install librsvg`) "
|
|
2763
|
+
"or Playwright (`npx playwright install chromium`), or set "
|
|
2764
|
+
"`preview_images.rasterizer: none` to make SVG-only the intent."
|
|
2765
|
+
)
|
|
1985
2766
|
return ImageResult(True, "svg", svg_path)
|
|
1986
2767
|
|
|
1987
2768
|
|
|
@@ -1999,7 +2780,7 @@ class LocalProvider(Provider, SvgProviderMixin):
|
|
|
1999
2780
|
|
|
2000
2781
|
def generate(self, prompt, settings, out_base, ctx) -> ImageResult:
|
|
2001
2782
|
seed = seed_for(ctx.slug or out_base.stem)
|
|
2002
|
-
svg_text = render_local_svg(ctx.slug, seed)
|
|
2783
|
+
svg_text = render_local_svg(ctx.slug, seed, ctx.article)
|
|
2003
2784
|
return self.finish_svg(svg_text, out_base, settings, ctx)
|
|
2004
2785
|
|
|
2005
2786
|
def edit(self, image_path, prompt, settings, ctx, out_path=None) -> ImageResult:
|
|
@@ -2013,6 +2794,123 @@ class LocalProvider(Provider, SvgProviderMixin):
|
|
|
2013
2794
|
|
|
2014
2795
|
# Renderers only — Claude is the orchestration layer (claude_article_brief /
|
|
2015
2796
|
# claude_review_image) that sits in front of ANY of these, not a provider.
|
|
2797
|
+
|
|
2798
|
+
class DefaultProvider(Provider, SvgProviderMixin):
|
|
2799
|
+
"""Key-free floor: ONE shared banner per collection/section.
|
|
2800
|
+
|
|
2801
|
+
The `local` provider draws a distinct procedural composition per article.
|
|
2802
|
+
That is right when the art is meant to be per-article, but when there is no
|
|
2803
|
+
AI credential anywhere it produces N near-identical images under N names —
|
|
2804
|
+
91 flow fields that differ only by palette read as one picture repeated, and
|
|
2805
|
+
cost 91 files to say so. This provider is honest about that instead: pages
|
|
2806
|
+
that were never touched by an image model share a single deliberate banner
|
|
2807
|
+
for their section, and the repo carries one file per section rather than one
|
|
2808
|
+
per page.
|
|
2809
|
+
|
|
2810
|
+
Upgrade path: wire any renderer credential and `provider: auto` moves off
|
|
2811
|
+
this rung automatically.
|
|
2812
|
+
"""
|
|
2813
|
+
|
|
2814
|
+
name = "default"
|
|
2815
|
+
|
|
2816
|
+
def is_configured(self, env: Dict[str, str]) -> bool:
|
|
2817
|
+
return True
|
|
2818
|
+
|
|
2819
|
+
def missing_hint(self, env: Dict[str, str]) -> str:
|
|
2820
|
+
return ""
|
|
2821
|
+
|
|
2822
|
+
def default_model(self) -> str:
|
|
2823
|
+
return "collection-default-svg"
|
|
2824
|
+
|
|
2825
|
+
def group_of(self, ctx: "RunContext") -> str:
|
|
2826
|
+
article = ctx.article or {}
|
|
2827
|
+
return str(article.get("section") or "").strip() or "default"
|
|
2828
|
+
|
|
2829
|
+
def output_stem(self, slug: str, ctx: "RunContext") -> str:
|
|
2830
|
+
# Every page in a section resolves to the same stem, so the runner
|
|
2831
|
+
# writes it once and stamps every page of that section at it.
|
|
2832
|
+
return f"section-{generate_filename(self.group_of(ctx)) or 'default'}"
|
|
2833
|
+
|
|
2834
|
+
def generate(self, prompt, settings, out_base, ctx) -> ImageResult:
|
|
2835
|
+
group = self.group_of(ctx)
|
|
2836
|
+
# Seeded by the SECTION, not the article: the whole point is that the
|
|
2837
|
+
# image is shared, so it must not vary with whichever page happened to
|
|
2838
|
+
# trigger the write.
|
|
2839
|
+
seed = seed_for(group)
|
|
2840
|
+
composition, palette = banner_theme_for(
|
|
2841
|
+
{"slug": group, "section": group, "categories": group,
|
|
2842
|
+
"title": group, "description": "", "body": ""})
|
|
2843
|
+
svg_text = BANNER_COMPOSITIONS[composition](
|
|
2844
|
+
seed, BANNER_PALETTES[palette], group)
|
|
2845
|
+
return self.finish_svg(svg_text, out_base, settings, ctx)
|
|
2846
|
+
|
|
2847
|
+
|
|
2848
|
+
class ClaudeSvgProvider(Provider, SvgProviderMixin):
|
|
2849
|
+
"""Claude authors the banner as SVG, from the article.
|
|
2850
|
+
|
|
2851
|
+
Claude cannot render pixels — the Anthropic API has no image endpoint — but
|
|
2852
|
+
it can WRITE vector markup, which is the one image format a language model
|
|
2853
|
+
can emit directly. That makes it the rung between "no credential at all"
|
|
2854
|
+
and "a raster image API": genuinely per-article art, no image-model spend.
|
|
2855
|
+
|
|
2856
|
+
The output goes through the same sanitizer as every other SVG, so a model
|
|
2857
|
+
that emits a <script>, a remote href, or a raster data: payload gets it
|
|
2858
|
+
stripped rather than committed.
|
|
2859
|
+
"""
|
|
2860
|
+
|
|
2861
|
+
name = "claude"
|
|
2862
|
+
|
|
2863
|
+
def is_configured(self, env: Dict[str, str]) -> bool:
|
|
2864
|
+
return AnthropicClient(env).available()
|
|
2865
|
+
|
|
2866
|
+
def missing_hint(self, env: Dict[str, str]) -> str:
|
|
2867
|
+
return CLAUDE_CREDENTIAL_HINT
|
|
2868
|
+
|
|
2869
|
+
def default_model(self) -> str:
|
|
2870
|
+
return DEFAULT_CLAUDE_MODEL
|
|
2871
|
+
|
|
2872
|
+
def generate(self, prompt, settings, out_base, ctx) -> ImageResult:
|
|
2873
|
+
article = ctx.article or {}
|
|
2874
|
+
instruction = (
|
|
2875
|
+
"Author a single SVG banner for this article. Reply with ONLY the "
|
|
2876
|
+
f"SVG markup, no prose and no code fence.\n\n"
|
|
2877
|
+
f"HARD REQUIREMENTS\n"
|
|
2878
|
+
f'- Root: <svg xmlns="{SVG_NS}" viewBox="0 0 {SVG_WIDTH} {SVG_HEIGHT}" '
|
|
2879
|
+
f'width="{SVG_WIDTH}" height="{SVG_HEIGHT}" role="img">\n'
|
|
2880
|
+
"- A <title> as the FIRST child describing the image.\n"
|
|
2881
|
+
"- Vector shapes only: no <script>, no <foreignObject>, no <image>, "
|
|
2882
|
+
"no external href, no data: payloads, no <text> (fonts will not "
|
|
2883
|
+
"resolve — convert lettering to paths or leave it out).\n"
|
|
2884
|
+
"- Fill the whole frame. No large empty regions.\n"
|
|
2885
|
+
"- Round coordinates to at most 2 decimals; stay under 200 KB.\n\n"
|
|
2886
|
+
f"ART DIRECTION\n{prompt}\n\n"
|
|
2887
|
+
f"ARTICLE\nTitle: {article.get('title', '')}\n"
|
|
2888
|
+
f"Section: {article.get('section', '')}\n"
|
|
2889
|
+
f"Tags: {article.get('categories', '')}\n"
|
|
2890
|
+
f"Description: {article.get('description', '')}\n"
|
|
2891
|
+
)
|
|
2892
|
+
try:
|
|
2893
|
+
reply = ctx.claude().complete(
|
|
2894
|
+
"You are an SVG illustrator. You reply with SVG markup only.",
|
|
2895
|
+
instruction,
|
|
2896
|
+
model=claude_model_for(settings),
|
|
2897
|
+
max_tokens=CLAUDE_MAX_TOKENS,
|
|
2898
|
+
effort=settings.claude_effort,
|
|
2899
|
+
)
|
|
2900
|
+
except Exception as exc: # noqa: BLE001 - any transport failure degrades
|
|
2901
|
+
return ImageResult(False, error=f"claude SVG authoring failed: {exc}")
|
|
2902
|
+
svg_text = _extract_svg(reply)
|
|
2903
|
+
if not svg_text:
|
|
2904
|
+
return ImageResult(False, error="claude returned no SVG markup")
|
|
2905
|
+
return self.finish_svg(svg_text, out_base, settings, ctx)
|
|
2906
|
+
|
|
2907
|
+
|
|
2908
|
+
def _extract_svg(text: str) -> str:
|
|
2909
|
+
"""Pull the <svg>…</svg> out of a model reply, fence or no fence."""
|
|
2910
|
+
match = re.search(r"<svg\b.*?</svg>", str(text or ""), re.S | re.I)
|
|
2911
|
+
return match.group(0) if match else ""
|
|
2912
|
+
|
|
2913
|
+
|
|
2016
2914
|
PROVIDERS: Dict[str, Provider] = {
|
|
2017
2915
|
provider.name: provider
|
|
2018
2916
|
for provider in (
|
|
@@ -2021,6 +2919,8 @@ PROVIDERS: Dict[str, Provider] = {
|
|
|
2021
2919
|
StabilityProvider(),
|
|
2022
2920
|
GeminiProvider(),
|
|
2023
2921
|
LocalProvider(),
|
|
2922
|
+
DefaultProvider(),
|
|
2923
|
+
ClaudeSvgProvider(),
|
|
2024
2924
|
)
|
|
2025
2925
|
}
|
|
2026
2926
|
|
|
@@ -2110,7 +3010,15 @@ class Runner:
|
|
|
2110
3010
|
warn(f"Cannot derive filename from title in {path}")
|
|
2111
3011
|
self.stats.inc("errors")
|
|
2112
3012
|
return
|
|
2113
|
-
|
|
3013
|
+
# The provider chooses the filename stem: per-article for the real
|
|
3014
|
+
# renderers, one shared file per collection for the key-free default.
|
|
3015
|
+
provider_for_paths = PROVIDERS.get(settings.provider)
|
|
3016
|
+
stem = slug
|
|
3017
|
+
if provider_for_paths is not None:
|
|
3018
|
+
stem = provider_for_paths.output_stem(
|
|
3019
|
+
slug, replace(self.ctx, slug=slug,
|
|
3020
|
+
article=article_art_context(cf, slug))) or slug
|
|
3021
|
+
out_base = self.root / settings.output_dir / stem
|
|
2114
3022
|
|
|
2115
3023
|
# Style overrides apply only once an image/prompt is actually built.
|
|
2116
3024
|
file_settings = file_preview_settings(settings, cf, self.authors)
|
|
@@ -2144,7 +3052,8 @@ class Runner:
|
|
|
2144
3052
|
# local provider derives its deterministic seed from it). The copy
|
|
2145
3053
|
# carries the shared AnthropicClient reference, which is stateless
|
|
2146
3054
|
# after init and therefore thread-safe.
|
|
2147
|
-
file_ctx = replace(self.ctx, slug=slug
|
|
3055
|
+
file_ctx = replace(self.ctx, slug=slug,
|
|
3056
|
+
article=article_art_context(cf, slug))
|
|
2148
3057
|
# ---- Produce: the selected raster model renders the brief ----
|
|
2149
3058
|
result = provider.generate(prompt, file_settings, out_base, file_ctx)
|
|
2150
3059
|
|
|
@@ -2363,8 +3272,10 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
|
|
2363
3272
|
help="Process one collection by name, or 'all' for every "
|
|
2364
3273
|
"configured collection")
|
|
2365
3274
|
parser.add_argument("-p", "--provider",
|
|
2366
|
-
choices=sorted(PROVIDERS.keys()),
|
|
2367
|
-
help="
|
|
3275
|
+
choices=sorted(list(PROVIDERS.keys()) + ["auto"]),
|
|
3276
|
+
help="Renderer, or `auto` to pick the best one the "
|
|
3277
|
+
"environment can reach (raster API > claude SVG > "
|
|
3278
|
+
"shared collection default)")
|
|
2368
3279
|
parser.add_argument("--model", help="Override the image/SVG model for the provider")
|
|
2369
3280
|
parser.add_argument("--output-dir",
|
|
2370
3281
|
help="Output directory for images (default: assets/images/previews)")
|
|
@@ -2393,7 +3304,8 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
|
|
2393
3304
|
"may request one refined regeneration (default: claude)")
|
|
2394
3305
|
parser.add_argument("--rasterizer",
|
|
2395
3306
|
choices=["auto", "rsvg", "inkscape", "magick", "playwright", "none"],
|
|
2396
|
-
help="SVG→PNG tool for claude/local providers (default:
|
|
3307
|
+
help="SVG→PNG tool for claude/local providers (default: "
|
|
3308
|
+
"auto; `none` keeps the .svg and writes no PNG)")
|
|
2397
3309
|
parser.add_argument("--style", help="Override image style prompt")
|
|
2398
3310
|
parser.add_argument("--assets-prefix", help="Assets prefix for path normalization")
|
|
2399
3311
|
parser.add_argument("--no-auto-prefix", action="store_true",
|
|
@@ -2425,6 +3337,60 @@ def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
|
|
|
2425
3337
|
return args
|
|
2426
3338
|
|
|
2427
3339
|
|
|
3340
|
+
# Renderers that produce raster art, best first. `auto` walks this list before
|
|
3341
|
+
# falling back to the SVG rungs.
|
|
3342
|
+
AUTO_RASTER_ORDER: List[str] = ["openai", "xai", "stability", "gemini"]
|
|
3343
|
+
|
|
3344
|
+
# Env vars that constitute a real Claude credential. Deliberately NOT the same
|
|
3345
|
+
# test as AnthropicClient.available(), which also accepts a `claude` binary on
|
|
3346
|
+
# PATH: an installed CLI is not proof of a working login, and `auto` promoting
|
|
3347
|
+
# an unauthenticated machine off the default rung would fail every page at
|
|
3348
|
+
# generation time instead of quietly shipping the shared banner. An explicit
|
|
3349
|
+
# `--provider claude` still rides the CLI, because that is a human asserting it
|
|
3350
|
+
# works.
|
|
3351
|
+
CLAUDE_CREDENTIAL_ENV: Tuple[str, ...] = (
|
|
3352
|
+
"CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY",
|
|
3353
|
+
)
|
|
3354
|
+
|
|
3355
|
+
|
|
3356
|
+
def _claude_credential_wired(env: Dict[str, str]) -> bool:
|
|
3357
|
+
return any(env.get(name) for name in CLAUDE_CREDENTIAL_ENV)
|
|
3358
|
+
|
|
3359
|
+
|
|
3360
|
+
def resolve_auto_provider(settings: Settings, env: Dict[str, str]) -> Settings:
|
|
3361
|
+
"""Pick the best renderer the environment can actually reach.
|
|
3362
|
+
|
|
3363
|
+
The ladder, best rung first:
|
|
3364
|
+
|
|
3365
|
+
1. a raster image API (OPENAI/XAI/STABILITY/GEMINI key) — and if a Claude
|
|
3366
|
+
credential is ALSO present, `prompt_engine: claude` already has Claude
|
|
3367
|
+
write the art brief that renderer works from, and review the result;
|
|
3368
|
+
2. a Claude credential alone — Claude authors the banner as SVG, which is
|
|
3369
|
+
the one image format a language model can emit directly;
|
|
3370
|
+
3. nothing wired — one shared, deliberate banner per collection.
|
|
3371
|
+
|
|
3372
|
+
Only `provider: auto` goes through this. An explicit provider is honored as
|
|
3373
|
+
written, so a site can always pin what it wants.
|
|
3374
|
+
"""
|
|
3375
|
+
if settings.provider != "auto":
|
|
3376
|
+
return settings
|
|
3377
|
+
|
|
3378
|
+
for name in AUTO_RASTER_ORDER:
|
|
3379
|
+
provider = PROVIDERS.get(name)
|
|
3380
|
+
if provider is not None and provider.is_configured(env):
|
|
3381
|
+
claude = " (Claude writes the brief)" if _claude_credential_wired(env) else ""
|
|
3382
|
+
info(f"provider: auto → {name}{claude}")
|
|
3383
|
+
return replace(settings, provider=name)
|
|
3384
|
+
|
|
3385
|
+
if _claude_credential_wired(env):
|
|
3386
|
+
info("provider: auto → claude (no raster image key; Claude authors the SVG)")
|
|
3387
|
+
return replace(settings, provider="claude")
|
|
3388
|
+
|
|
3389
|
+
info("provider: auto → default (no image credential wired; "
|
|
3390
|
+
"one shared banner per collection)")
|
|
3391
|
+
return replace(settings, provider="default")
|
|
3392
|
+
|
|
3393
|
+
|
|
2428
3394
|
def validate_credentials(settings: Settings, ctx: RunContext) -> None:
|
|
2429
3395
|
"""Credential checks are skipped for --list-missing/--dry-run (historical
|
|
2430
3396
|
behavior), but an unknown provider name (from AI_PROVIDER / _config.yml —
|
|
@@ -2440,6 +3406,11 @@ def validate_credentials(settings: Settings, ctx: RunContext) -> None:
|
|
|
2440
3406
|
return
|
|
2441
3407
|
if not provider.is_configured(ctx.env):
|
|
2442
3408
|
error_exit(provider.missing_hint(ctx.env))
|
|
3409
|
+
# Providers with more than one way in (xAI: OAuth token, then API key) say
|
|
3410
|
+
# which one this run picked, so a silent fallback is never a surprise.
|
|
3411
|
+
description = provider.auth_description(ctx.env)
|
|
3412
|
+
if description:
|
|
3413
|
+
info(f"{provider.name} auth: {description}")
|
|
2443
3414
|
|
|
2444
3415
|
|
|
2445
3416
|
def main(argv: Optional[List[str]] = None) -> int:
|
|
@@ -2476,12 +3447,13 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
2476
3447
|
|
|
2477
3448
|
print_header("🎨 Preview Image Generator")
|
|
2478
3449
|
ctx = RunContext(project_root=project_root, env=dict(os.environ))
|
|
3450
|
+
settings = resolve_auto_provider(settings, ctx.env)
|
|
2479
3451
|
validate_credentials(settings, ctx)
|
|
2480
3452
|
|
|
2481
3453
|
# Claude orchestration (analyze/review) degrades gracefully: without a
|
|
2482
3454
|
# Claude credential the run continues on template prompts, unreviewed.
|
|
2483
3455
|
wants_claude = (
|
|
2484
|
-
settings.provider
|
|
3456
|
+
settings.provider not in ("local", "default")
|
|
2485
3457
|
and "claude" in (settings.prompt_engine, settings.review_engine)
|
|
2486
3458
|
and not (settings.dry_run or settings.list_only)
|
|
2487
3459
|
)
|