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
|
@@ -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("&", "&").gsub("<", "<").gsub(">", ">")
|
|
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
|