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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/README.md +106 -2
  4. data/lib/zer0_image_generator/abc/style_pack.rb +145 -0
  5. data/lib/zer0_image_generator/abc.rb +75 -0
  6. data/lib/zer0_image_generator/all.rb +57 -0
  7. data/lib/zer0_image_generator/claude/client.rb +291 -0
  8. data/lib/zer0_image_generator/claude/orchestration.rb +128 -0
  9. data/lib/zer0_image_generator/cli.rb +318 -0
  10. data/lib/zer0_image_generator/config.rb +192 -0
  11. data/lib/zer0_image_generator/constants.rb +177 -0
  12. data/lib/zer0_image_generator/content.rb +379 -0
  13. data/lib/zer0_image_generator/engine.rb +21 -0
  14. data/lib/zer0_image_generator/freesvg/cache.rb +167 -0
  15. data/lib/zer0_image_generator/freesvg/client.rb +467 -0
  16. data/lib/zer0_image_generator/http.rb +264 -0
  17. data/lib/zer0_image_generator/library.rb +201 -0
  18. data/lib/zer0_image_generator/logging.rb +236 -0
  19. data/lib/zer0_image_generator/preview_generator.py +1072 -100
  20. data/lib/zer0_image_generator/prompt.rb +48 -0
  21. data/lib/zer0_image_generator/providers/base.rb +152 -0
  22. data/lib/zer0_image_generator/providers/gemini.rb +60 -0
  23. data/lib/zer0_image_generator/providers/local.rb +90 -0
  24. data/lib/zer0_image_generator/providers/openai.rb +102 -0
  25. data/lib/zer0_image_generator/providers/stability.rb +64 -0
  26. data/lib/zer0_image_generator/providers/xai.rb +81 -0
  27. data/lib/zer0_image_generator/providers/xai_auth.rb +270 -0
  28. data/lib/zer0_image_generator/providers.rb +22 -0
  29. data/lib/zer0_image_generator/runner.rb +573 -0
  30. data/lib/zer0_image_generator/settings.rb +386 -0
  31. data/lib/zer0_image_generator/stats.rb +61 -0
  32. data/lib/zer0_image_generator/support/py_random.rb +189 -0
  33. data/lib/zer0_image_generator/svg/banner_seed.rb +241 -0
  34. data/lib/zer0_image_generator/svg/generators/flowfield.rb +154 -0
  35. data/lib/zer0_image_generator/svg/generators/invaders.rb +125 -0
  36. data/lib/zer0_image_generator/svg/generators/lowpoly.rb +254 -0
  37. data/lib/zer0_image_generator/svg/generators/lsystem.rb +228 -0
  38. data/lib/zer0_image_generator/svg/generators/mandala.rb +155 -0
  39. data/lib/zer0_image_generator/svg/generators/pixelquest.rb +144 -0
  40. data/lib/zer0_image_generator/svg/generators/starmap.rb +179 -0
  41. data/lib/zer0_image_generator/svg/lint.rb +400 -0
  42. data/lib/zer0_image_generator/svg/local_renderer.rb +606 -0
  43. data/lib/zer0_image_generator/svg/pixel_kit.rb +167 -0
  44. data/lib/zer0_image_generator/svg/rasterizer.rb +196 -0
  45. data/lib/zer0_image_generator/svg/sanitizer.rb +159 -0
  46. data/lib/zer0_image_generator/version.rb +1 -1
  47. metadata +43 -2
@@ -0,0 +1,606 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+ require "set"
5
+ require_relative "sanitizer" # for SVG_NS / SVG_WIDTH / SVG_HEIGHT — the shared geometry contract
6
+
7
+ module Zer0ImageGenerator
8
+ module Svg
9
+ # Deterministic local banner renderer. HARD CONTRACT (.claude/rules/svg.md):
10
+ # same slug -> byte-identical banner. Every random draw goes through the
11
+ # seeded LCG (nxt); nothing here may touch a clock, a real RNG, or the
12
+ # environment. The seed -> palette/composition scheme (seed_for /
13
+ # palette_for / composition_for) is shared with the claude provider and is
14
+ # a cross-slice contract — changing it re-skins every existing banner.
15
+ module LocalRenderer
16
+ # Curated retro palettes: [sky/background, far, mid, near, accent, glow].
17
+ # Selected deterministically per slug so re-runs stay visually stable.
18
+ RETRO_PALETTES = [
19
+ %w[#1a1a2e #16213e #0f3460 #533483 #e94560 #f9ed69],
20
+ %w[#0d1b2a #1b263b #415a77 #778da9 #e0e1dd #ffb703],
21
+ %w[#2d132c #801336 #c72c41 #ee4540 #f9d276 #f4f4f4],
22
+ %w[#10002b #3c096c #7b2cbf #c77dff #e0aaff #72efdd],
23
+ %w[#001219 #005f73 #0a9396 #94d2bd #e9d8a6 #ee9b00],
24
+ %w[#03071e #370617 #9d0208 #dc2f02 #f48c06 #ffba08],
25
+ %w[#0b132b #1c2541 #3a506b #5bc0be #6fffe9 #ff6b6b],
26
+ %w[#232931 #393e46 #4ecca3 #a5ecd7 #eeeeee #f95959],
27
+ %w[#1f0a24 #571089 #ab51e3 #f7aef8 #b388eb #8093f1],
28
+ %w[#141e30 #243b55 #3c6382 #82ccdd #f8c291 #e55039],
29
+ ].freeze
30
+
31
+ # Only the length matters to render_local_svg (variant selection), but the
32
+ # full list is the shared contract also consumed by composition_for.
33
+ COMPOSITION_VARIANTS = [
34
+ "a low horizon with a huge rising sun disk banded by scanlines",
35
+ "layered diagonal mountain silhouettes receding into haze",
36
+ "a vaporwave perspective grid floor vanishing toward the horizon",
37
+ "floating terraced islands with cascading pixel waterfalls",
38
+ "a night starfield with a large ringed planet arcing across the frame",
39
+ "a stepped city skyline of blocky towers with lit windows",
40
+ "rolling desert dunes with a lone monolith and long shadows",
41
+ "an ocean of chunky pixel waves under drifting square clouds",
42
+ ].freeze
43
+
44
+ module_function
45
+
46
+ # zlib.crc32(slug.encode("utf-8")) == Ruby Zlib.crc32(slug). Zlib.crc32
47
+ # already returns a non-negative 32-bit int, so no masking is needed to
48
+ # match CPython's unsigned crc32.
49
+ def seed_for(slug)
50
+ Zlib.crc32(slug)
51
+ end
52
+
53
+ def palette_for(seed)
54
+ RETRO_PALETTES[seed % RETRO_PALETTES.length]
55
+ end
56
+
57
+ def composition_for(seed)
58
+ COMPOSITION_VARIANTS[(seed >> 8) % COMPOSITION_VARIANTS.length]
59
+ end
60
+
61
+ # Deterministic retro-landscape SVG for the `local` provider (no network).
62
+ #
63
+ # Shares the claude provider's seed -> palette/composition scheme so the
64
+ # two stay visually kin per slug. BYTE-IDENTICAL to Python's
65
+ # render_local_svg for the same (title, seed) — verified over 200+ slugs
66
+ # by test/ruby/test_local_renderer.rb.
67
+ # --- Content-aware banner art -----------------------------------------
68
+ # Port of the banner section of preview_generator.py, which stays the
69
+ # oracle. The renderer used to draw ONE composition (a retro skyline
70
+ # under a sun) re-coloured per slug, so every banner on a site looked
71
+ # like every other and none had anything to do with what the article
72
+ # said. These compositions replace it: the page's own context picks the
73
+ # composition AND the palette.
74
+ #
75
+ # Everything is authored at the banner frame, and every draw goes through
76
+ # the seeded LCG — no clock, no Random, no environment.
77
+
78
+ # [bg, far, mid, near, accent, glow]
79
+ BANNER_PALETTES = {
80
+ "neon" => %w[#0b0f17 #171b2e #2b2a5e #d946ef #22d3ee #f5f3ff],
81
+ "ember" => %w[#120b16 #2d1b2e #5c2a4a #b3324b #f2542d #ffd166],
82
+ "cyanic" => %w[#0b132b #1c2541 #1c7293 #5bc0be #9fd8d6 #e8f1f2],
83
+ "moss" => %w[#101a14 #1b2f22 #2f5d3f #6f9e6f #a3c9a8 #e9edc9],
84
+ "dusk" => %w[#1a1423 #2c1f3d #5c3a72 #a05fa8 #f5a623 #f7e8d3],
85
+ "slate" => %w[#0d1117 #161b22 #26303d #4b6178 #7ea6c9 #dbe7f0],
86
+ }.freeze
87
+ BANNER_PALETTE_ORDER = %w[neon ember cyanic moss dusk slate].freeze
88
+
89
+ # Matched as WHOLE WORDS, so section names must appear in the form a
90
+ # section directory actually uses ('hacks', not 'hack').
91
+ BANNER_KEYWORDS = [
92
+ ["circuit", %w[hack hacks shell bash zsh cli terminal command script
93
+ docker container pipeline deploy git kernel server
94
+ network port cache sed awk grep ssh make compile]],
95
+ ["pixelscene", %w[tool tools review reviews app editor game retro
96
+ install cheatsheet productivity keyboard plugin
97
+ extension terminal]],
98
+ ["starfield", %w[security threat secret secrets token auth permission
99
+ permissions audit risk attack privacy explore journey
100
+ discover vulnerability credential sandbox]],
101
+ ["lowpoly", %w[doc docs guide reference architecture design system
102
+ structure schema diagram overview spec concept concepts
103
+ glossary handbook]],
104
+ ["flowfield", %w[note notes essay story ai agent agents robot automation
105
+ loop flow process thought retrospective lesson lessons
106
+ journal reflection]],
107
+ ].freeze
108
+ BANNER_COMPOSITION_ORDER = %w[circuit flowfield lowpoly pixelscene starfield].freeze
109
+
110
+ BANNER_TAU = 6.283185307179586
111
+
112
+ module_function
113
+
114
+ # glibc LCG masked to 31 bits — the same determinism seam the rest of the
115
+ # engine draws from. Returns a lambda so each composition owns its stream.
116
+ def banner_lcg(seed)
117
+ state = seed.nil? || seed.zero? ? 1 : seed
118
+ lambda do |bound|
119
+ state = ((state * 1103515245) + 12345) & 0x7FFFFFFF
120
+ state % [bound, 1].max
121
+ end
122
+ end
123
+
124
+ # Round for markup. Mirrors Python's f"{v:.Nf}".rstrip("0").rstrip(".")
125
+ # exactly — the trailing-zero strip is what keeps the bytes identical.
126
+ def banner_num(value, places = 1)
127
+ text = format("%.#{places}f", value)
128
+ text = text.sub(/0+\z/, "").sub(/\.\z/, "")
129
+ text.empty? ? "0" : text
130
+ end
131
+
132
+ # Blend two #rrggbb strings. Integer output, so the bytes do not depend on
133
+ # float formatting.
134
+ def banner_mix(a, b, t)
135
+ t = [[t, 0.0].max, 1.0].min
136
+ out = +"#"
137
+ [1, 3, 5].each do |i|
138
+ ca = a[i, 2].to_i(16)
139
+ cb = b[i, 2].to_i(16)
140
+ out << format("%02x", (ca + ((cb - ca) * t) + 0.5).to_i)
141
+ end
142
+ out
143
+ end
144
+
145
+ # Trig by lookup table, built from a Taylor series rather than Math.sin:
146
+ # libm rounds the last bit differently across platforms and runtimes, and
147
+ # this file is pinned byte-for-byte against the Python. 256 entries is
148
+ # 1.4 degrees — finer than a 1px step can express at this size.
149
+ BANNER_SIN = []
150
+ BANNER_COS = []
151
+ 256.times do |i|
152
+ a = (i / 256.0) * BANNER_TAU
153
+ t = a > 3.141592653589793 ? a - BANNER_TAU : a
154
+ BANNER_SIN << (t - (t**3 / 6) + (t**5 / 120) - (t**7 / 5040) + (t**9 / 362880))
155
+ BANNER_COS << (1 - (t**2 / 2) + (t**4 / 24) - (t**6 / 720) + (t**8 / 40320) -
156
+ (t**10 / 3628800))
157
+ end
158
+ BANNER_SIN.freeze
159
+ BANNER_COS.freeze
160
+
161
+ def banner_sin(angle)
162
+ BANNER_SIN[(angle / BANNER_TAU * 256).to_i & 255]
163
+ end
164
+
165
+ def banner_cos(angle)
166
+ BANNER_COS[(angle / BANNER_TAU * 256).to_i & 255]
167
+ end
168
+
169
+ # Smoothed value noise on a 17x17 lattice, sampled in unit coordinates.
170
+ def banner_noise(seed)
171
+ nxt = banner_lcg(seed ^ 0x9E3779B9)
172
+ grid = Array.new(17) { Array.new(17) { nxt.call(1000) / 1000.0 } }
173
+ lambda do |x, y|
174
+ gx = x * 16.0
175
+ gy = y * 16.0
176
+ x0 = gx.to_i
177
+ y0 = gy.to_i
178
+ fx = gx - x0
179
+ fy = gy - y0
180
+ x1 = [x0 + 1, 16].min
181
+ y1 = [y0 + 1, 16].min
182
+ # smoothstep, so the field has no directional creases
183
+ sx = fx * fx * (3 - (2 * fx))
184
+ sy = fy * fy * (3 - (2 * fy))
185
+ a = (grid[y0][x0] * (1 - sx)) + (grid[y0][x1] * sx)
186
+ b = (grid[y1][x0] * (1 - sx)) + (grid[y1][x1] * sx)
187
+ (a * (1 - sy)) + (b * sy)
188
+ end
189
+ end
190
+
191
+ # `role="img"` plus a `<title>` first child is the house rule
192
+ # (.claude/rules/svg.md) — without it a screen reader meets an unlabeled
193
+ # graphic.
194
+ def banner_frame(body, pal, title = "")
195
+ label = (title.to_s.empty? ? "banner" : title.to_s)
196
+ .gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
197
+ %(<svg xmlns="#{SVG_NS}" viewBox="0 0 #{SVG_WIDTH} #{SVG_HEIGHT}" ) +
198
+ %(width="#{SVG_WIDTH}" height="#{SVG_HEIGHT}" role="img">) +
199
+ %(<title>Preview banner: #{label}</title>) +
200
+ %(<rect width="#{SVG_WIDTH}" height="#{SVG_HEIGHT}" fill="#{pal[0]}" />) +
201
+ %(#{body}</svg>)
202
+ end
203
+
204
+ # Streamlines advected through a noise field — organic and dense, with no
205
+ # dead space anywhere in the frame.
206
+ def banner_flowfield(seed, pal, title = "")
207
+ nxt = banner_lcg(seed)
208
+ noise = banner_noise(seed)
209
+ w = SVG_WIDTH
210
+ h = SVG_HEIGHT
211
+ inks = [pal[3], pal[4], pal[5], pal[2]]
212
+ out = []
213
+ 400.times do
214
+ x = nxt.call(w + 240) - 120
215
+ y = nxt.call(h + 240) - 120
216
+ pts = [[x, y]]
217
+ # Advance in SMALL steps so the curve stays faithful to the field, but
218
+ # record only every third. Mapping the 0..1 noise onto ONE turn (not
219
+ # two) keeps adjacent steps from flipping ~180 degrees, which would
220
+ # otherwise stutter the line into a zigzag.
221
+ 96.times do |i|
222
+ angle = noise.call([[x / w.to_f, 0.0].max, 1.0].min,
223
+ [[y / h.to_f, 0.0].max, 1.0].min) * BANNER_TAU
224
+ x += banner_cos(angle) * 9
225
+ y += banner_sin(angle) * 9
226
+ break unless x > -140 && x < w + 140 && y > -140 && y < h + 140
227
+
228
+ pts << [x, y] if (i % 3) == 2
229
+ end
230
+ next if pts.length < 5
231
+
232
+ ink = inks[nxt.call(inks.length)]
233
+ width = 0.5 + (nxt.call(18) / 10.0)
234
+ opacity = 0.16 + (nxt.call(50) / 100.0)
235
+ # Whole pixels: at these stroke widths a decimal is invisible and
236
+ # costs two bytes on each of ~12k coordinate pairs.
237
+ d = "M" + pts.map { |px, py| "#{px.to_i} #{py.to_i}" }.join(" L")
238
+ out << %(<path d="#{d}" fill="none" stroke="#{ink}" ) +
239
+ %(stroke-width="#{banner_num(width)}" ) +
240
+ %(stroke-opacity="#{banner_num(opacity, 2)}" ) +
241
+ %(stroke-linecap="round" />)
242
+ end
243
+ banner_frame(out.join, pal, title)
244
+ end
245
+
246
+ # Faceted gradient field — calm and technical, fills the frame edge to
247
+ # edge.
248
+ def banner_lowpoly(seed, pal, title = "")
249
+ nxt = banner_lcg(seed)
250
+ noise = banner_noise(seed)
251
+ w = SVG_WIDTH
252
+ h = SVG_HEIGHT
253
+ cols = 22
254
+ rows = 15
255
+ cw = w / cols.to_f
256
+ ch = h / rows.to_f
257
+ lattice = (0..rows).map do |r|
258
+ (0..cols).map do |c|
259
+ # Edge vertices stay pinned, or the background shows through.
260
+ jx = [0, cols].include?(c) ? 0.0 : (nxt.call(60) - 30) / 100.0 * cw
261
+ jy = [0, rows].include?(r) ? 0.0 : (nxt.call(60) - 30) / 100.0 * ch
262
+ [(c * cw) + jx, (r * ch) + jy]
263
+ end
264
+ end
265
+
266
+ # Ramp CONTINUOUSLY down the frame and let the noise only shade each
267
+ # facet slightly. Discrete stops band the field into hard stripes and an
268
+ # accent threshold scatters floating shards.
269
+ ramp = [pal[1], pal[2], pal[3], pal[4]]
270
+ facet_color = lambda do |t, n|
271
+ pos = [[t, 0.0].max, 0.999].min * (ramp.length - 1)
272
+ i = pos.to_i
273
+ base = banner_mix(ramp[i], ramp[i + 1], pos - i)
274
+ n > 0.5 ? banner_mix(base, pal[5], (n - 0.5) * 0.22)
275
+ : banner_mix(base, pal[0], (0.5 - n) * 0.30)
276
+ end
277
+
278
+ out = []
279
+ rows.times do |r|
280
+ cols.times do |c|
281
+ a = lattice[r][c]
282
+ b = lattice[r][c + 1]
283
+ d = lattice[r + 1][c]
284
+ e = lattice[r + 1][c + 1]
285
+ [[a, b, d], [b, e, d]].each do |tri|
286
+ cx = (tri[0][0] + tri[1][0] + tri[2][0]) / 3
287
+ cy = (tri[0][1] + tri[1][1] + tri[2][1]) / 3
288
+ color = facet_color.call(cy / h, noise.call(cx / w, cy / h))
289
+ points = tri.map { |px, py| "#{banner_num(px)},#{banner_num(py)}" }.join(" ")
290
+ # Stroke each facet in its own fill: float rounding otherwise
291
+ # leaves hairline seams that render as stray light lines.
292
+ out << %(<polygon points="#{points}" fill="#{color}" ) +
293
+ %(stroke="#{color}" stroke-width="1" />)
294
+ end
295
+ end
296
+ end
297
+ banner_frame(out.join, pal, title)
298
+ end
299
+
300
+ # Orthogonal traces, pads, vias and chips — a schematic read for the
301
+ # shell/infrastructure end of a site.
302
+ def banner_circuit(seed, pal, title = "")
303
+ nxt = banner_lcg(seed)
304
+ w = SVG_WIDTH
305
+ h = SVG_HEIGHT
306
+ step = 32
307
+ out = []
308
+
309
+ substrate = []
310
+ (0..w).step(step * 2) { |x| substrate << "M#{x} 0 L#{x} #{h}" }
311
+ (0..h).step(step * 2) { |y| substrate << "M0 #{y} L#{w} #{y}" }
312
+ out << %(<path d="#{substrate.join(" ")}" stroke="#{pal[1]}" ) +
313
+ %(stroke-width="1" fill="none" stroke-opacity="0.9" />)
314
+
315
+ junctions = []
316
+ 64.times do
317
+ # Snap to the grid so every trace is orthogonal by construction.
318
+ x = nxt.call(w / step) * step
319
+ y = nxt.call(h / step) * step
320
+ segments = ["M#{x} #{y}"]
321
+ junctions << [x, y]
322
+ (3 + nxt.call(5)).times do
323
+ if nxt.call(2) != 0
324
+ x = [[x + ((nxt.call(11) - 5) * step), 0].max, w].min
325
+ segments << "L#{x} #{y}"
326
+ else
327
+ y = [[y + ((nxt.call(9) - 4) * step), 0].max, h].min
328
+ segments << "L#{x} #{y}"
329
+ end
330
+ end
331
+ junctions << [x, y]
332
+ ink = nxt.call(3) != 0 ? pal[4] : pal[3]
333
+ out << %(<path d="#{segments.join(" ")}" fill="none" stroke="#{ink}" ) +
334
+ %(stroke-width="#{2 + nxt.call(3)}" stroke-opacity="0.85" ) +
335
+ %(stroke-linecap="square" stroke-linejoin="miter" />)
336
+ end
337
+
338
+ junctions.each do |px, py|
339
+ out << %(<circle cx="#{px}" cy="#{py}" r="7" fill="#{pal[0]}" ) +
340
+ %(stroke="#{pal[4]}" stroke-width="3" />)
341
+ end
342
+
343
+ 6.times do
344
+ x = nxt.call((w / step) - 6) * step
345
+ y = nxt.call((h / step) - 4) * step
346
+ cw = (3 + nxt.call(3)) * step
347
+ chh = (2 + nxt.call(2)) * step
348
+ out << %(<rect x="#{x}" y="#{y}" width="#{cw}" height="#{chh}" rx="6" ) +
349
+ %(fill="#{pal[2]}" stroke="#{pal[5]}" stroke-width="2" />)
350
+ (cw / step * 2).times do |leg|
351
+ out << %(<rect x="#{x + 8 + (leg * (step / 2))}" y="#{y + chh}" ) +
352
+ %(width="6" height="10" fill="#{pal[5]}" />)
353
+ end
354
+ end
355
+ banner_frame(out.join, pal, title)
356
+ end
357
+
358
+ # Constellations over layered ridges — the night-sky read.
359
+ def banner_starfield(seed, pal, title = "")
360
+ nxt = banner_lcg(seed)
361
+ w = SVG_WIDTH
362
+ h = SVG_HEIGHT
363
+ horizon = (h * 0.72).to_i
364
+ out = []
365
+
366
+ 230.times do
367
+ x = nxt.call(w)
368
+ y = nxt.call(horizon)
369
+ # Thin the field toward the horizon so the sky reads as deep.
370
+ next if nxt.call(horizon) < y * 0.75
371
+
372
+ out << %(<circle cx="#{x}" cy="#{y}" r="#{1 + nxt.call(3)}" fill="#{pal[5]}" ) +
373
+ %(fill-opacity="#{banner_num(0.3 + (nxt.call(70) / 100.0), 2)}" />)
374
+ end
375
+
376
+ 4.times do
377
+ cx = 120 + nxt.call(w - 240)
378
+ cy = 90 + nxt.call(horizon - 220)
379
+ nodes = [[cx, cy]]
380
+ (3 + nxt.call(3)).times do
381
+ cx += nxt.call(220) - 90
382
+ cy += nxt.call(180) - 80
383
+ nodes << [cx, cy]
384
+ end
385
+ d = "M" + nodes.map { |nx, ny| "#{nx} #{ny}" }.join(" L")
386
+ out << %(<path d="#{d}" fill="none" stroke="#{pal[4]}" ) +
387
+ %(stroke-width="2" stroke-opacity="0.55" />)
388
+ nodes.each do |nx, ny|
389
+ out << %(<circle cx="#{nx}" cy="#{ny}" r="5" fill="#{pal[4]}" />)
390
+ end
391
+ end
392
+
393
+ mx = w - 260 - nxt.call(200)
394
+ my = 150 + nxt.call(120)
395
+ out << %(<circle cx="#{mx}" cy="#{my}" r="86" fill="#{pal[3]}" ) +
396
+ %(fill-opacity="0.18" />)
397
+ out << %(<circle cx="#{mx}" cy="#{my}" r="62" fill="#{pal[5]}" />)
398
+ out << %(<circle cx="#{mx + 26}" cy="#{my - 16}" r="56" fill="#{pal[0]}" />)
399
+
400
+ # Aerial perspective: the FARTHEST ridge is hazy and light, each nearer
401
+ # one steps darker. Every layer closes to the bottom of the frame, so
402
+ # the foreground is ridge mass rather than a flat band of background.
403
+ 4.times do |layer|
404
+ top = horizon - 40 + (layer * 58)
405
+ points = ["0,#{h}"]
406
+ x = 0
407
+ y = top.to_f
408
+ while x <= w
409
+ points << "#{x},#{banner_num(y)}"
410
+ x += 40 + nxt.call(80)
411
+ y = top + nxt.call(56) - 24
412
+ end
413
+ points << "#{w},#{banner_num(top.to_f)}"
414
+ points << "#{w},#{h}"
415
+ out << %(<polygon points="#{points.join(" ")}" ) +
416
+ %(fill="#{banner_mix(pal[3], pal[0], layer / 3.0)}" />)
417
+ end
418
+ banner_frame(out.join, pal, title)
419
+ end
420
+
421
+ # Pixel-art side-scroller on a strict integer grid, run-length merged.
422
+ def banner_pixelscene(seed, pal, title = "")
423
+ nxt = banner_lcg(seed)
424
+ px = 16
425
+ cols = SVG_WIDTH / px
426
+ rows = SVG_HEIGHT / px
427
+ cells = Array.new(rows) { Array.new(cols) }
428
+
429
+ put = lambda do |cx, cy, color|
430
+ cells[cy][cx] = color if cx >= 0 && cx < cols && cy >= 0 && cy < rows
431
+ end
432
+ block = lambda do |cx, cy, bw, bh, color|
433
+ (cy...(cy + bh)).each do |yy|
434
+ (cx...(cx + bw)).each { |xx| put.call(xx, yy, color) }
435
+ end
436
+ end
437
+
438
+ ground = rows - 12
439
+ 150.times do
440
+ sx = nxt.call(cols)
441
+ sy = nxt.call(ground - 4)
442
+ put.call(sx, sy, nxt.call(4) != 0 ? pal[5] : pal[3]) if nxt.call(ground) > sy
443
+ end
444
+
445
+ sun_x = cols - 20 + nxt.call(8)
446
+ sun_y = 6 + nxt.call(5)
447
+ (-4..4).each do |yy|
448
+ (-4..4).each do |xx|
449
+ d2 = (xx * xx) + (yy * yy)
450
+ if d2 <= 16
451
+ put.call(sun_x + xx, sun_y + yy, pal[4])
452
+ elsif d2 <= 25
453
+ put.call(sun_x + xx, sun_y + yy, pal[3])
454
+ end
455
+ end
456
+ end
457
+
458
+ # `surface` is the row the ground starts at; fill from there to the
459
+ # bottom so the land is solid mass, not a one-cell scribble.
460
+ surface = ground
461
+ cols.times do |cx|
462
+ surface = [[surface + nxt.call(3) - 1, ground - 5].max, rows - 4].min if nxt.call(4).zero?
463
+ block.call(cx, surface, 1, rows - surface, pal[2])
464
+ put.call(cx, surface, pal[3])
465
+ put.call(cx, surface + 2 + nxt.call(4), pal[1]) if nxt.call(7).zero?
466
+ end
467
+
468
+ 5.times do
469
+ span = 5 + nxt.call(8)
470
+ bx = nxt.call([cols - span, 1].max)
471
+ by = ground - 9 - nxt.call(16)
472
+ span.times do |i|
473
+ put.call(bx + i, by, pal[3])
474
+ put.call(bx + i, by + 1, pal[1])
475
+ end
476
+ end
477
+
478
+ # The hero: 6x9 cells with a dark keyline, so it reads off any ground
479
+ # colour and stays the only glow-coloured figure in frame.
480
+ hx = 10 + nxt.call(14)
481
+ hy = ground - 10
482
+ block.call(hx - 1, hy - 1, 8, 11, pal[0])
483
+ block.call(hx + 1, hy, 4, 2, pal[4])
484
+ block.call(hx + 1, hy + 2, 4, 1, pal[1])
485
+ block.call(hx, hy + 3, 6, 4, pal[5])
486
+ block.call(hx + 2, hy + 4, 2, 2, pal[3])
487
+ block.call(hx, hy + 7, 2, 3, pal[5])
488
+ block.call(hx + 4, hy + 7, 2, 3, pal[5])
489
+ block.call(hx + 6, hy + 1, 1, 6, pal[4])
490
+
491
+ # Merge horizontal runs of one colour into single rects: a 96x64 scene
492
+ # stays a few KB instead of six thousand one-cell rects.
493
+ out = []
494
+ rows.times do |y|
495
+ x = 0
496
+ while x < cols
497
+ color = cells[y][x]
498
+ if color.nil?
499
+ x += 1
500
+ next
501
+ end
502
+ run = 1
503
+ run += 1 while x + run < cols && cells[y][x + run] == color
504
+ out << %(<rect x="#{x * px}" y="#{y * px}" width="#{run * px}" ) +
505
+ %(height="#{px}" fill="#{color}" />)
506
+ x += run
507
+ end
508
+ end
509
+ body = %(<g shape-rendering="crispEdges">#{out.join}</g>)
510
+ veil = (0...SVG_HEIGHT).step(6).map do |y|
511
+ %(<rect y="#{y}" width="#{SVG_WIDTH}" height="2" />)
512
+ end.join
513
+ body += %(<g fill="#{pal[0]}" fill-opacity="0.22">#{veil}</g>)
514
+ banner_frame(body, pal, title)
515
+ end
516
+
517
+ BANNER_COMPOSITIONS = {
518
+ "circuit" => :banner_circuit,
519
+ "flowfield" => :banner_flowfield,
520
+ "lowpoly" => :banner_lowpoly,
521
+ "pixelscene" => :banner_pixelscene,
522
+ "starfield" => :banner_starfield,
523
+ }.freeze
524
+
525
+ BANNER_WORD = /[a-z0-9]+/.freeze
526
+
527
+ # Whole words, lowercased. Substring matching is not good enough here: a
528
+ # bare "ci" fires inside 'decision', 'specific' and 'efficiency', which is
529
+ # enough to drag every article on a dev blog toward one look.
530
+ def banner_words(text)
531
+ text.to_s.downcase.scan(BANNER_WORD)
532
+ end
533
+
534
+ def banner_signature(article)
535
+ [
536
+ article["section"], article["categories"], article["title"],
537
+ article["description"], article["body"].to_s[0, 600]
538
+ ].map(&:to_s).join(" ").downcase
539
+ end
540
+
541
+ # Seed from the article's CONTENT, not just its filename — so retitling or
542
+ # re-tagging a piece re-rolls its art, and two posts that merely sort next
543
+ # to each other do not come out kin.
544
+ def banner_seed_for(article)
545
+ Zlib.crc32("#{banner_signature(article)}|#{article["slug"]}")
546
+ end
547
+
548
+ # (composition, palette) for an article. Keyword hits decide; the seed only
549
+ # breaks ties and picks the palette, so the mapping stays legible.
550
+ #
551
+ # The fields are weighted, not pooled. A section name is a deliberate
552
+ # editorial choice and the tags and title are hand-written, so they carry
553
+ # most of the signal; the body is long, incidental, and full of words the
554
+ # author never chose, so its whole contribution is capped.
555
+ def banner_theme_for(article)
556
+ fields = [
557
+ [article["section"], 6],
558
+ [article["categories"], 3],
559
+ [article["title"], 3],
560
+ [article["description"], 2],
561
+ [article["body"].to_s[0, 4000], 1],
562
+ ]
563
+ scored = fields.map { |text, weight| [banner_words(text).to_set, weight] }
564
+ seed = banner_seed_for(article)
565
+
566
+ best_name = nil
567
+ best_score = 0
568
+ BANNER_KEYWORDS.each do |name, keywords|
569
+ wanted = keywords.to_set
570
+ score = 0
571
+ scored.each do |words, weight|
572
+ hits = (wanted & words).size
573
+ # Cap the body's contribution: presence, not word count, is what the
574
+ # low-weight field is evidence of.
575
+ score += weight == 1 ? [hits, 4].min * weight : hits * weight
576
+ end
577
+ # Strictly greater, so ties fall to the earlier (more specific) entry.
578
+ if score > best_score
579
+ best_name = name
580
+ best_score = score
581
+ end
582
+ end
583
+ best_name ||= BANNER_COMPOSITION_ORDER[seed % BANNER_COMPOSITION_ORDER.length]
584
+
585
+ [best_name, BANNER_PALETTE_ORDER[(seed >> 8) % BANNER_PALETTE_ORDER.length]]
586
+ end
587
+
588
+ # Deterministic banner for the `local` provider (no network).
589
+ #
590
+ # `article` carries the page's context (slug, section, title, tags, body);
591
+ # it selects the composition and palette. Without it — a caller that only
592
+ # has a slug — the art still renders, chosen from the seed alone.
593
+ def render_local_svg(title, seed, article = nil)
594
+ if article && !article.empty?
595
+ composition, palette = banner_theme_for(article)
596
+ art_seed = banner_seed_for(article)
597
+ else
598
+ composition = BANNER_COMPOSITION_ORDER[seed % BANNER_COMPOSITION_ORDER.length]
599
+ palette = BANNER_PALETTE_ORDER[(seed >> 8) % BANNER_PALETTE_ORDER.length]
600
+ art_seed = seed
601
+ end
602
+ public_send(BANNER_COMPOSITIONS[composition], art_seed, BANNER_PALETTES[palette], title)
603
+ end
604
+ end
605
+ end
606
+ end