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,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
|