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,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../support/py_random"
4
+ # See invaders.rb for why the PixelKit load is guarded.
5
+ require_relative "../pixel_kit" unless defined?(Zer0ImageGenerator::Svg::PixelKit)
6
+
7
+ module Zer0ImageGenerator
8
+ module Svg
9
+ module Generators
10
+ # Side-scroller vignette — port of `tools/svg/examples/pixelquest.py`.
11
+ #
12
+ # A knight, a slime, coins, a crate, and a goal flag under a blocky sunset.
13
+ # Hand-designed sprites, seeded layout: the seed drives terrain, star and
14
+ # coin placement, and where the cast stands.
15
+ #
16
+ # Byte-identity contract: `docs/samples/pixel-quest-dusk-7.svg` and the
17
+ # `docs/showcase/variants/pixel-quest-*.svg` were produced here. The
18
+ # terrain random-walk and starfield draw values in a precise order — a
19
+ # color pick only fires when a star is actually placed — so the stream
20
+ # must be reproduced draw-for-draw.
21
+ module PixelQuest
22
+ HERO = [
23
+ "...cc...",
24
+ "..cccc..",
25
+ ".hhhhhh.",
26
+ ".hfefeh.",
27
+ ".hffffh.",
28
+ "..aaaa..",
29
+ ".gaaaag.",
30
+ ".gaaaag.",
31
+ "..a..a..",
32
+ "..b..b..",
33
+ "..b..b..",
34
+ ].freeze
35
+
36
+ SLIME = [
37
+ "..ssss..",
38
+ ".ssssss.",
39
+ "ssssssss",
40
+ "ss.ss.ss",
41
+ "ssssssss",
42
+ ].freeze
43
+
44
+ COIN = [
45
+ ".##.",
46
+ "#oo#",
47
+ "#oo#",
48
+ ".##.",
49
+ ].freeze
50
+
51
+ CRATE = [
52
+ "######",
53
+ "#o##o#",
54
+ "######",
55
+ "######",
56
+ "#o##o#",
57
+ "######",
58
+ ].freeze
59
+
60
+ FLAG = [
61
+ "pff.",
62
+ "pfff",
63
+ "pff.",
64
+ "p...",
65
+ "p...",
66
+ "p...",
67
+ "p...",
68
+ "p...",
69
+ "p...",
70
+ "p...",
71
+ ].freeze
72
+
73
+ DEFAULT_PALETTE = "dusk"
74
+
75
+ # Coin arc over the crate, not confetti: (dx, dy) offsets.
76
+ COIN_ARC = [[-6, 10], [0, 12], [6, 10]].freeze
77
+
78
+ class << self
79
+ def build(seed, palette = DEFAULT_PALETTE)
80
+ rng = Support::PyRandom.new(seed)
81
+ pal = Svg::PixelKit::PALETTES.fetch(palette)
82
+ canvas = Svg::Canvas.new(64, 40, px: 16, bg: pal[0])
83
+ ground_top = 34
84
+
85
+ # Stars, thinning toward the horizon. The color draw is inside the
86
+ # branch — only placed stars consume it.
87
+ 60.times do
88
+ col = rng.randrange(64)
89
+ row = rng.randrange(24)
90
+ if rng.random < (1.0 - (row / 24.0))
91
+ canvas.set(col, row, rng.random < 0.7 ? pal[1] : pal[2])
92
+ end
93
+ end
94
+
95
+ # Blocky setting sun
96
+ sun_x = 40 + rng.randrange(12)
97
+ canvas.disc(sun_x, 9, 5.2, pal[4])
98
+ canvas.disc(sun_x, 9, 3.4, pal[5])
99
+
100
+ # Stepped far hills, then near hills (random walk, engine-style)
101
+ [[26, pal[1]], [30, pal[2]]].each do |base, color|
102
+ col = 0
103
+ level = base - rng.randrange(3)
104
+ while col < 64
105
+ width = 3 + rng.randrange(5)
106
+ canvas.fill(col, level, width, ground_top - level, color)
107
+ col += width
108
+ level = [base - 6, [base, level + rng.choice([-2, -1, 1, 2])].min].max
109
+ end
110
+ end
111
+
112
+ # Brick ground with staggered mortar and a lit cap row
113
+ canvas.fill(0, ground_top, 64, 6, pal[2])
114
+ (ground_top...40).each do |row|
115
+ 64.times do |col|
116
+ if row.odd? && ((col + (3 * (row / 2))) % 6).zero?
117
+ canvas.set(col, row, pal[1])
118
+ end
119
+ end
120
+ end
121
+ canvas.fill(0, ground_top, 64, 1, pal[4])
122
+
123
+ # The cast
124
+ hero_col = 10 + rng.randrange(6)
125
+ canvas.blit(HERO, { "c" => pal[3], "h" => pal[5], "f" => pal[4], "e" => pal[0],
126
+ "a" => pal[5], "g" => pal[5], "b" => pal[3] },
127
+ hero_col, ground_top - 11)
128
+ canvas.blit(SLIME, { "s" => pal[3] }, 38 + rng.randrange(6), ground_top - 5)
129
+ crate_col = 27
130
+ canvas.blit(CRATE, { "#" => pal[4], "o" => pal[1] }, crate_col, ground_top - 6)
131
+ canvas.blit(FLAG, { "p" => pal[5], "f" => pal[3] }, 58, ground_top - 10)
132
+ # Coin arc over the crate, not confetti
133
+ COIN_ARC.each do |dx, dy|
134
+ canvas.blit(COIN, { "#" => pal[4], "o" => pal[5] },
135
+ crate_col + 1 + dx, ground_top - dy - 4)
136
+ end
137
+
138
+ canvas.to_svg("Pixel quest, seed #{seed}, palette #{palette}", crt: true)
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../support/py_random"
4
+
5
+ module Zer0ImageGenerator
6
+ module Svg
7
+ module Generators
8
+ # Composed scene from library assets + generated elements — port of
9
+ # `tools/svg/examples/starmap.py`.
10
+ #
11
+ # The "reuse gathered content" path: it inlines MIT-licensed line icons
12
+ # from tools/svg/library/external/ (lucide, feather, tabler) as the cast,
13
+ # over a generated seeded starfield and a low-poly hill silhouette. Icons
14
+ # are stroke="currentColor", so each is recolored and scaled on placement
15
+ # with a counter-scaled stroke width to keep line weight constant
16
+ # regardless of size.
17
+ #
18
+ # Byte-identity contract: `docs/samples/starmap-5.svg` was produced here.
19
+ # The starfield, hill ridges, and per-facet shading all draw from
20
+ # `Support::PyRandom` in a data-dependent order (a star's size/opacity is
21
+ # only drawn when the star survives the thinning test), so the stream must
22
+ # be reproduced draw-for-draw and every float formatted like Python's.
23
+ module Starmap
24
+ # Resolved like the Python original: relative to this file, walk up to
25
+ # the repo root and into the tracked icon library. Kept overridable so a
26
+ # host that vendors the icons elsewhere can point at them without a fork;
27
+ # the default preserves byte-identity with the committed sample.
28
+ DEFAULT_LIBRARY_ROOT = File.join(
29
+ File.expand_path("../../../..", __dir__), "tools", "svg", "library", "external"
30
+ ).freeze
31
+
32
+ PALETTE = {
33
+ "sky_top" => "#0a1330", "sky_bot" => "#1b2a4a", "haze" => "#2a3f66",
34
+ "hill_far" => "#213a63", "hill_near" => "#0c1c38",
35
+ "star" => "#e8f1f2", "moon" => "#f2e9c9", "moon_glow" => "#8bbfd6",
36
+ "rocket" => "#f7e8d3", "planet" => "#5bc0be", "accent" => "#f2a541"
37
+ }.freeze
38
+
39
+ WIDTH = 1200
40
+ HEIGHT = 800
41
+
42
+ STAR_SIZES = [0.6, 0.9, 1.2, 1.8].freeze
43
+
44
+ HALF = Rational(1, 2)
45
+ private_constant :HALF
46
+
47
+ class << self
48
+ def build(seed, library_root: DEFAULT_LIBRARY_ROOT)
49
+ rng = Support::PyRandom.new(seed)
50
+ w = WIDTH
51
+ h = HEIGHT
52
+ horizon = (h * 0.72).to_i
53
+ p = PALETTE
54
+ out = [
55
+ %(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 #{w} #{h}" role="img">),
56
+ "<title>Star map: a rocket among planets over low-poly hills " \
57
+ "(composed from MIT library icons)</title>",
58
+ "<defs>",
59
+ %(<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">) +
60
+ %(<stop offset="0" stop-color="#{p['sky_top']}"/>) +
61
+ %(<stop offset="1" stop-color="#{p['sky_bot']}"/></linearGradient>),
62
+ %(<radialGradient id="glow"><stop offset="0" stop-color="#{p['moon_glow']}" stop-opacity="0.5"/>) +
63
+ %(<stop offset="1" stop-color="#{p['moon_glow']}" stop-opacity="0"/></radialGradient>),
64
+ "</defs>",
65
+ %(<rect width="#{w}" height="#{h}" fill="url(#sky)"/>),
66
+ ]
67
+
68
+ # Seeded starfield, thinning toward the horizon
69
+ 150.times do
70
+ x = rng.randrange(w)
71
+ y = rng.randrange(horizon)
72
+ next if rng.random > ((1 - (y / horizon.to_f)) * 0.9)
73
+
74
+ r = rng.choice(STAR_SIZES)
75
+ out << %(<circle cx="#{x}" cy="#{y}" r="#{py_float(r)}" fill="#{p['star']}" ) +
76
+ %(opacity="#{fmt(rng.uniform(0.3, 0.95), 2)}"/>)
77
+ end
78
+
79
+ # Moon with glow, upper right
80
+ mx = (w * 0.78).to_i
81
+ my = (h * 0.24).to_i
82
+ out << %(<circle cx="#{mx}" cy="#{my}" r="150" fill="url(#glow)"/>)
83
+ out << %(<circle cx="#{mx}" cy="#{my}" r="62" fill="#{p['moon']}"/>)
84
+ out << %(<circle cx="#{mx + 22}" cy="#{my - 14}" r="54" fill="#{p['sky_top']}" opacity="0.55"/>)
85
+
86
+ # Low-poly hill silhouettes: two ridges triangulated into facets
87
+ out << ridge(rng, horizon, 70, p["hill_far"], 90)
88
+ out << ridge(rng, horizon + 60, 46, p["hill_near"], 70)
89
+
90
+ # Cast: gathered MIT icons, recolored and scaled
91
+ rocket = inner(library_root, "lucide/lucide-rocket.svg")
92
+ planet = inner(library_root, "tabler/tabler-planet.svg")
93
+ star = inner(library_root, "feather/feather-star.svg")
94
+ moon_i = inner(library_root, "feather/feather-moon.svg")
95
+
96
+ out << place(planet, (w * 0.2).to_i, (h * 0.3).to_i, 5.2, p["planet"], sw: 2.6)
97
+ out << place(rocket, (w * 0.52).to_i, (h * 0.44).to_i, 9.0, p["rocket"], sw: 2.2)
98
+ out << place(star, (w * 0.36).to_i, (h * 0.16).to_i, 2.0, p["accent"], sw: 2.4)
99
+ out << place(star, (w * 0.64).to_i, (h * 0.6).to_i, 1.5, p["accent"], sw: 2.4, op: 0.8)
100
+ out << place(star, (w * 0.88).to_i, (h * 0.52).to_i, 1.8, p["accent"], sw: 2.4, op: 0.9)
101
+ out << place(moon_i, (w * 0.1).to_i, (h * 0.12).to_i, 2.4, p["moon_glow"], sw: 2.2)
102
+
103
+ out << "</svg>"
104
+ "#{out.join("\n")}\n"
105
+ end
106
+
107
+ private
108
+
109
+ # Strip the outer <svg> wrapper from a library icon, keep its marks.
110
+ # Mirrors Python's slicing: from the first '>' at/after '<svg' up to
111
+ # the last '</svg>', then whitespace-trimmed.
112
+ def inner(library_root, rel)
113
+ t = File.read(File.join(library_root, rel), encoding: "utf-8")
114
+ t = t[(t.index(">", t.index("<svg")) + 1)..]
115
+ t[0...t.rindex("</svg>")].strip
116
+ end
117
+
118
+ # Place a 24x24 icon centered at (cx,cy), scaled, stroke width held
119
+ # constant on screen by counter-scaling it against the group scale.
120
+ def place(frag, cx, cy, s, color, sw: 2.4, op: 1.0)
121
+ %(<g transform="translate(#{fmt(cx - (12 * s), 1)} #{fmt(cy - (12 * s), 1)}) ) +
122
+ %(scale(#{fmt(s, 3)})" fill="none" stroke="#{color}" stroke-width="#{fmt(sw / s, 3)}" ) +
123
+ %(stroke-linecap="round" stroke-linejoin="round" opacity="#{py_float(op)}">#{frag}</g>)
124
+ end
125
+
126
+ # Two ridges triangulated into facets. Draw order is load-bearing:
127
+ # every column's height (randrange) is drawn first as a batch, then
128
+ # one shade (random) per triangle — matching Python's list
129
+ # comprehension followed by the per-facet loop.
130
+ def ridge(rng, base, amp, color, step)
131
+ cols = (-40...(WIDTH + 80)).step(step).to_a
132
+ top = cols.map { base - rng.randrange(amp) }
133
+ # smooth once
134
+ top = Array.new(top.length) do |i|
135
+ (top[[0, i - 1].max] + top[i] + top[[top.length - 1, i + 1].min]) / 3
136
+ end
137
+ tris = []
138
+ (0...(cols.length - 1)).each do |i|
139
+ x0 = cols[i]
140
+ x1 = cols[i + 1]
141
+ y0 = top[i]
142
+ y1 = top[i + 1]
143
+ shade = 0.68 + (0.42 * rng.random) # wider spread so facets read
144
+ c = "#" + [1, 3, 5].map do |j|
145
+ v = [0, [255, (color[j, 2].to_i(16) * shade).to_i].min].max
146
+ format("%02x", v)
147
+ end.join
148
+ tris << %(<polygon points="#{x0},#{y0} #{x1},#{y1} #{x1},#{HEIGHT} #{x0},#{HEIGHT}" ) +
149
+ %(fill="#{c}" stroke="#{c}" stroke-width="1"/>)
150
+ end
151
+ tris.join("\n")
152
+ end
153
+
154
+ # Exact correctly-rounded fixed-point, matching Python's `f"{x:.Nf}"`
155
+ # (round-half-to-even on the exact binary value). Ruby's `%f`/`round`
156
+ # round the shortest decimal instead, which diverges on ties.
157
+ def fmt(value, places)
158
+ negative = value.negative? || (value.zero? && (1.0 / value).negative?)
159
+ scaled = value.abs.to_r * (10**places)
160
+ whole = scaled.floor
161
+ remainder = scaled - whole
162
+ whole += 1 if remainder > HALF || (remainder == HALF && whole.odd?)
163
+
164
+ digits = whole.to_s.rjust(places + 1, "0")
165
+ text = places.zero? ? digits : "#{digits[0...-places]}.#{digits[-places..]}"
166
+ negative ? "-#{text}" : text
167
+ end
168
+
169
+ # Bare `{x}` interpolation in Python prints float's shortest
170
+ # round-tripping repr. Ruby's Float#to_s does the same for the values
171
+ # used here (small non-integral and integral-valued floats).
172
+ def py_float(value)
173
+ value.to_s
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end