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,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "open3"
5
+ require "timeout"
6
+
7
+ require_relative "../constants"
8
+ require_relative "../http"
9
+ require_relative "../logging"
10
+
11
+ module Zer0ImageGenerator
12
+ # Claude declined the request (Anthropic `stop_reason == "refusal"`). Carries
13
+ # the refusal category so callers can surface an actionable message rather
14
+ # than a generic failure.
15
+ class ClaudeRefusal < StandardError
16
+ attr_reader :category
17
+
18
+ def initialize(category)
19
+ @category = category
20
+ # `category or 'unspecified'`: both nil and "" degrade to "unspecified".
21
+ label = category.nil? || category == "" ? "unspecified" : category
22
+ super("request declined (category: #{label})")
23
+ end
24
+ end
25
+
26
+ # Claude hit the max_tokens ceiling mid-answer (`stop_reason == "max_tokens"`).
27
+ # Distinct from a refusal so the runner can retry with a smaller ask instead
28
+ # of giving up. The partial text is carried as the message.
29
+ class ClaudeTruncated < StandardError; end
30
+
31
+ # Minimal Messages-API client honoring the repo's credential conventions.
32
+ #
33
+ # Modes (first match wins — mirrors templates/deploy/chat-proxy/worker.js):
34
+ # oauth CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_AUTH_TOKEN -> Bearer +
35
+ # `anthropic-beta: oauth-2025-04-20` + Claude Code first system block
36
+ # api_key ANTHROPIC_API_KEY -> x-api-key header
37
+ # cli `claude -p` headless (rides the local Claude Code login)
38
+ #
39
+ # `transport` is an injectable HTTP seam (defaults to the Http module); it must
40
+ # respond to `json(url, payload, headers, timeout:)`. `which` resolves an
41
+ # executable on PATH and is injectable so tests need not depend on whether the
42
+ # real `claude` binary is installed.
43
+ class AnthropicClient
44
+ attr_reader :mode, :token
45
+
46
+ def initialize(env = nil, transport: nil, which: nil)
47
+ # `dict(env if env is not None else os.environ)` — a snapshot, not a view.
48
+ @env = env.nil? ? ENV.to_h : env.to_h
49
+ @transport = transport || Http
50
+ @which = which || method(:default_which)
51
+ @mode = nil
52
+ @token = nil
53
+
54
+ if present?(@env["CLAUDE_CODE_OAUTH_TOKEN"])
55
+ @mode = "oauth"
56
+ @token = @env["CLAUDE_CODE_OAUTH_TOKEN"]
57
+ elsif present?(@env["ANTHROPIC_AUTH_TOKEN"])
58
+ @mode = "oauth"
59
+ @token = @env["ANTHROPIC_AUTH_TOKEN"]
60
+ elsif present?(@env["ANTHROPIC_API_KEY"])
61
+ @mode = "api_key"
62
+ @token = @env["ANTHROPIC_API_KEY"]
63
+ elsif @which.call("claude")
64
+ @mode = "cli"
65
+ end
66
+ end
67
+
68
+ def available?
69
+ !@mode.nil?
70
+ end
71
+
72
+ def describe
73
+ {
74
+ "oauth" => "Claude Code OAuth token (Bearer)",
75
+ "api_key" => "Anthropic API key",
76
+ "cli" => "claude CLI (local Claude Code login)",
77
+ nil => "none"
78
+ }[@mode]
79
+ end
80
+
81
+ def headers
82
+ case @mode
83
+ when "oauth"
84
+ {
85
+ "Authorization" => "Bearer #{@token}",
86
+ "anthropic-version" => ANTHROPIC_VERSION,
87
+ "anthropic-beta" => OAUTH_BETA
88
+ }
89
+ when "api_key"
90
+ {
91
+ "x-api-key" => @token.to_s,
92
+ "anthropic-version" => ANTHROPIC_VERSION
93
+ }
94
+ else
95
+ raise "no API headers for mode #{@mode}"
96
+ end
97
+ end
98
+
99
+ # One Messages-API turn (or CLI run); returns concatenated text blocks.
100
+ # Raises ClaudeRefusal / ClaudeTruncated / HttpStatusError / RuntimeError.
101
+ def complete(system_text, user_text, model: DEFAULT_CLAUDE_MODEL,
102
+ max_tokens: CLAUDE_MAX_TOKENS, effort: "")
103
+ return complete_cli(system_text, user_text, model) if @mode == "cli"
104
+ raise "no Anthropic credential configured" if @mode.nil?
105
+
106
+ payload = {
107
+ "model" => model,
108
+ "max_tokens" => max_tokens,
109
+ "thinking" => { "type" => "adaptive" },
110
+ "system" => system_blocks(system_text),
111
+ "messages" => [{ "role" => "user", "content" => user_text }]
112
+ }
113
+ payload["output_config"] = { "effort" => effort } unless effort.to_s.empty?
114
+
115
+ begin
116
+ data = api_call(payload, "Anthropic API")
117
+ rescue HttpStatusError => e
118
+ message = e.status == 400 ? e.error_message.downcase : ""
119
+ if e.status == 400 && (message.include?("output_config") || message.include?("effort"))
120
+ Logging.debug("Retrying without output_config parameter")
121
+ payload.delete("output_config")
122
+ data = api_call(payload, "Anthropic API")
123
+ elsif e.status == 400 && message.include?("thinking")
124
+ Logging.debug("Retrying without thinking parameter")
125
+ payload.delete("thinking")
126
+ data = api_call(payload, "Anthropic API")
127
+ elsif e.status == 401 && @mode == "oauth"
128
+ raise "Anthropic rejected the OAuth token (401). It may be expired " \
129
+ "— mint a fresh one with `claude setup-token`, or use " \
130
+ "ANTHROPIC_API_KEY instead."
131
+ else
132
+ raise
133
+ end
134
+ end
135
+
136
+ stop_reason = data["stop_reason"]
137
+ if stop_reason == "refusal"
138
+ details = data["stop_details"] || {}
139
+ raise ClaudeRefusal.new(details.is_a?(Hash) ? details["category"] : nil)
140
+ end
141
+
142
+ text = join_text(data)
143
+ raise ClaudeTruncated, text if stop_reason == "max_tokens"
144
+
145
+ text
146
+ end
147
+
148
+ # One vision turn over a local PNG (review stage). CLI mode passes the file
149
+ # path and lets `claude -p` read it; API modes embed base64.
150
+ def complete_vision(system_text, user_text, image_path, model: DEFAULT_CLAUDE_MODEL,
151
+ max_tokens: 2048, effort: "")
152
+ if @mode == "cli"
153
+ prompt = "#{system_text}\n\n---\n\nFirst use the Read tool to view the " \
154
+ "image file at #{File.expand_path(image_path.to_s)} — then respond.\n\n#{user_text}"
155
+ return run_cli(prompt, model, allowed_tools: "Read")
156
+ end
157
+ raise "no Anthropic credential configured" if @mode.nil?
158
+
159
+ payload = {
160
+ "model" => model,
161
+ "max_tokens" => max_tokens,
162
+ "thinking" => { "type" => "adaptive" },
163
+ "system" => system_blocks(system_text),
164
+ "messages" => [{
165
+ "role" => "user",
166
+ "content" => [
167
+ { "type" => "image", "source" => {
168
+ "type" => "base64", "media_type" => "image/png",
169
+ "data" => Base64.strict_encode64(File.binread(image_path.to_s))
170
+ } },
171
+ { "type" => "text", "text" => user_text }
172
+ ]
173
+ }]
174
+ }
175
+ payload["output_config"] = { "effort" => effort } unless effort.to_s.empty?
176
+
177
+ begin
178
+ data = api_call(payload, "Anthropic API (review)")
179
+ rescue HttpStatusError => e
180
+ message = e.status == 400 ? e.error_message.downcase : ""
181
+ if e.status == 400 && (message.include?("output_config") || message.include?("effort"))
182
+ Logging.debug("Retrying review without output_config parameter")
183
+ payload.delete("output_config")
184
+ data = api_call(payload, "Anthropic API (review)")
185
+ else
186
+ raise
187
+ end
188
+ end
189
+
190
+ if data["stop_reason"] == "refusal"
191
+ details = data["stop_details"] || {}
192
+ raise ClaudeRefusal.new(details.is_a?(Hash) ? details["category"] : nil)
193
+ end
194
+ join_text(data)
195
+ end
196
+
197
+ private
198
+
199
+ # The Claude Code identity block is REQUIRED as the first system block for
200
+ # OAuth tokens (worker.js recipe) and intentionally omitted for plain API
201
+ # keys, matching the chat proxy's per-mode behavior.
202
+ def system_blocks(system_text)
203
+ blocks = [{ "type" => "text", "text" => system_text }]
204
+ blocks.unshift({ "type" => "text", "text" => CLAUDE_CODE_SYSTEM_PROMPT }) if @mode == "oauth"
205
+ blocks
206
+ end
207
+
208
+ def api_call(payload, what)
209
+ Http.with_retries(what) do
210
+ @transport.json(ANTHROPIC_API_URL, payload, headers, timeout: 900)
211
+ end
212
+ end
213
+
214
+ def join_text(data)
215
+ (data["content"] || [])
216
+ .select { |block| block.is_a?(Hash) && block["type"] == "text" }
217
+ .map { |block| block["text"] || "" }
218
+ .join
219
+ end
220
+
221
+ def complete_cli(system_text, user_text, model)
222
+ run_cli("#{system_text}\n\n---\n\n#{user_text}", model)
223
+ end
224
+
225
+ def run_cli(prompt, model, allowed_tools: nil)
226
+ cmd = ["claude", "-p", "--model", model, "--output-format", "text"]
227
+ cmd += ["--allowedTools", allowed_tools] if allowed_tools
228
+ begin
229
+ stdout, stderr, status = capture_cli(cmd, prompt, 300)
230
+ rescue SystemCallError, Timeout::Error => e
231
+ # Python catches (OSError, subprocess.TimeoutExpired) → RuntimeError.
232
+ raise "claude CLI failed: #{e}"
233
+ end
234
+ if !status.success? || stdout.strip.empty?
235
+ stripped = (stderr || "").strip
236
+ tail = stripped.length > 300 ? stripped[-300..] : stripped
237
+ raise "claude CLI exited #{status.exitstatus}: #{tail.empty? ? 'no output' : tail}"
238
+ end
239
+ stdout
240
+ end
241
+
242
+ # subprocess.run(input=..., capture_output=True, text=True, timeout=300):
243
+ # feed stdin, drain stdout+stderr concurrently (a single sequential read can
244
+ # deadlock when the child fills the other pipe), and kill on timeout.
245
+ def capture_cli(cmd, input, seconds)
246
+ Open3.popen3(*cmd) do |stdin, stdout, stderr, wait_thr|
247
+ out_reader = Thread.new { stdout.read }
248
+ err_reader = Thread.new { stderr.read }
249
+ begin
250
+ Timeout.timeout(seconds) do
251
+ stdin.write(input)
252
+ stdin.close
253
+ [out_reader.value, err_reader.value, wait_thr.value]
254
+ end
255
+ rescue Timeout::Error
256
+ Process.kill("KILL", wait_thr.pid)
257
+ wait_thr.join
258
+ raise
259
+ end
260
+ end
261
+ end
262
+
263
+ def present?(value)
264
+ !value.nil? && value != ""
265
+ end
266
+
267
+ # shutil.which("claude"): search the real PATH for an executable, returning
268
+ # its path or nil. Reads the process environment (not @env) to match Python,
269
+ # whose shutil.which consults os.environ regardless of the client's env dict.
270
+ def default_which(program)
271
+ (ENV["PATH"] || "").split(File::PATH_SEPARATOR).each do |dir|
272
+ next if dir.empty?
273
+
274
+ candidate = File.join(dir, program)
275
+ return candidate if File.file?(candidate) && File.executable?(candidate)
276
+ end
277
+ nil
278
+ end
279
+ end
280
+
281
+ # The 4-step credential chain, spelled out for the human when no Claude
282
+ # credential is configured. Precedence matches AnthropicClient's detection.
283
+ CLAUDE_CREDENTIAL_HINT =
284
+ "Claude orchestration (article analysis + image review) needs any ONE of:\n" \
285
+ " 1. CLAUDE_CODE_OAUTH_TOKEN — run `claude setup-token` (Claude Pro/Max)\n" \
286
+ " 2. ANTHROPIC_AUTH_TOKEN — short-lived Bearer (e.g. `ant auth print-credentials`)\n" \
287
+ " 3. ANTHROPIC_API_KEY — key from console.anthropic.com\n" \
288
+ " 4. the `claude` CLI installed and logged in (used automatically)\n" \
289
+ "Falling back to the template prompt and skipping review (or pass " \
290
+ "--prompt-engine template --review none to silence this)."
291
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "../constants"
6
+ require_relative "../content"
7
+ require_relative "../logging"
8
+
9
+ module Zer0ImageGenerator
10
+ # The analyze + review stages: Claude ORCHESTRATES (writes the art-direction
11
+ # brief, vision-reviews the render), an image model RENDERS. Both stages are
12
+ # enhancement layers that degrade to the template prompt / an approval on any
13
+ # failure — neither may ever be a hard dependency of a generation run.
14
+ #
15
+ # The `client` argument is duck-typed against AnthropicClient (`complete`,
16
+ # `complete_vision`), so a Rails app can inject a fake without this module
17
+ # depending on the client class.
18
+ module Orchestration
19
+ module_function
20
+
21
+ def claude_model_for(settings)
22
+ # `settings.claude_model or DEFAULT_CLAUDE_MODEL`: an empty configured
23
+ # model falls through to the engine default.
24
+ model = settings.claude_model
25
+ model.to_s.empty? ? DEFAULT_CLAUDE_MODEL : model
26
+ end
27
+
28
+ # Analyze stage (prompt_engine: claude): Claude reads the article and writes
29
+ # the art-direction brief the renderer will receive. Falls back to the
30
+ # template prompt on any failure.
31
+ def claude_article_brief(client, cf, settings, base_prompt)
32
+ excerpt = Content.collapse_whitespace((cf.content || "")[0, 1500])
33
+ article =
34
+ "ARTICLE\nTitle: #{Content.py_str(cf.title)}\n" \
35
+ "Description: #{none_if_blank(cf.description)}\n" \
36
+ "Categories/tags: #{none_if_blank(cf.categories)}\n" \
37
+ "Excerpt: #{none_if_blank(excerpt)}\n\n" \
38
+ "MANDATORY STYLE DIRECTIONS\nArt style: #{Content.py_str(settings.style)}\n" \
39
+ "Style modifiers: #{none_if_blank(settings.style_modifiers)}\n\n" \
40
+ "Write the image-generation prompt now."
41
+
42
+ begin
43
+ text = py_strip(client.complete(
44
+ ART_DIRECTOR_SYSTEM, article,
45
+ model: claude_model_for(settings), max_tokens: 2048,
46
+ effort: settings.claude_effort
47
+ ))
48
+ unless text.empty?
49
+ Logging.debug("Claude art-direction brief: #{text[0, 300]}...")
50
+ return text
51
+ end
52
+ Logging.warn("Claude analysis returned empty text; using template prompt")
53
+ rescue StandardError => e
54
+ Logging.warn("Claude analysis failed (#{e.message}); using template prompt")
55
+ end
56
+ base_prompt
57
+ end
58
+
59
+ # Tolerant JSON extraction: the reviewer is told "JSON only" but models
60
+ # occasionally wrap prose or fences around it, so we take the outermost
61
+ # brace pair and parse that.
62
+ def extract_json_object(text)
63
+ start = text.index("{")
64
+ finish = text.rindex("}")
65
+ return nil if start.nil? || finish.nil? || finish <= start
66
+
67
+ begin
68
+ data = JSON.parse(text[start..finish])
69
+ rescue JSON::ParserError
70
+ return nil
71
+ end
72
+ data.is_a?(Hash) ? data : nil
73
+ end
74
+
75
+ # Review stage (review_engine: claude): Claude inspects the rendered banner.
76
+ # Returns [approved, critique, revised_prompt]. Any failure counts as
77
+ # approval — review must never block generation.
78
+ def claude_review_image(client, image_path, cf, prompt, settings)
79
+ mods = settings.style_modifiers
80
+ style_line = Content.py_str(settings.style) +
81
+ (Content.truthy?(mods) ? "; #{Content.py_str(mods)}" : "")
82
+ context =
83
+ "ARTICLE\nTitle: #{Content.py_str(cf.title)}\n" \
84
+ "Description: #{none_if_blank(cf.description)}\n" \
85
+ "Categories/tags: #{none_if_blank(cf.categories)}\n\n" \
86
+ "REQUIRED STYLE\n#{style_line}" \
87
+ "\n\nPROMPT THE IMAGE WAS GENERATED FROM\n#{Content.py_str(prompt)}\n\n" \
88
+ "Review the image above against the article and style. JSON only."
89
+
90
+ begin
91
+ text = client.complete_vision(
92
+ REVIEWER_SYSTEM, context, image_path,
93
+ model: claude_model_for(settings), effort: settings.claude_effort
94
+ )
95
+ data = extract_json_object(text)
96
+ unless data
97
+ Logging.warn("Claude review returned no parseable verdict; keeping image")
98
+ return [true, "", ""]
99
+ end
100
+ verdict = Content.py_str(data.fetch("verdict", "approve")).downcase
101
+ critique = py_strip(Content.py_str(data.fetch("critique", "")))
102
+ revised = py_strip(Content.py_str(data.fetch("revised_prompt", "")))
103
+ return [false, critique, revised] if verdict == "revise" && !revised.empty?
104
+
105
+ [true, critique, ""]
106
+ rescue StandardError => e
107
+ Logging.warn("Claude review failed (#{e.message}); keeping image")
108
+ [true, "", ""]
109
+ end
110
+ end
111
+
112
+ # `value or '(none)'` with CPython truthiness: nil, false, and "" become
113
+ # the "(none)" placeholder; everything else prints via str().
114
+ def none_if_blank(value)
115
+ Content.truthy?(value) ? Content.py_str(value) : "(none)"
116
+ end
117
+
118
+ # `str.strip()` with CPython's whitespace set. Reuses the content layer's
119
+ # single source of truth for that set rather than duplicating the codepoint
120
+ # table (Ruby's own String#strip is ASCII-only and also eats NUL, which
121
+ # Python's does not). Unlike collapse_whitespace this trims edges only.
122
+ PY_STRIP_RE = /\A[#{Content::PY_SPACE_CLASS}]+|[#{Content::PY_SPACE_CLASS}]+\z/.freeze
123
+
124
+ def py_strip(text)
125
+ text.to_s.gsub(PY_STRIP_RE, "")
126
+ end
127
+ end
128
+ end