zer0-image-generator 0.8.0 → 0.9.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 +13 -0
- data/README.md +14 -6
- data/lib/zer0_image_generator/all.rb +3 -2
- data/lib/zer0_image_generator/claude/client.rb +3 -2
- data/lib/zer0_image_generator/cli.rb +33 -15
- data/lib/zer0_image_generator/command.rb +2 -2
- data/lib/zer0_image_generator/constants.rb +11 -0
- data/lib/zer0_image_generator/facade.rb +248 -0
- data/lib/zer0_image_generator/grok/client.rb +92 -0
- data/lib/zer0_image_generator/http.rb +14 -0
- data/lib/zer0_image_generator/preview_generator.py +427 -43
- data/lib/zer0_image_generator/providers/base.rb +12 -3
- data/lib/zer0_image_generator/providers/openai.rb +3 -1
- data/lib/zer0_image_generator/providers/xai.rb +27 -1
- data/lib/zer0_image_generator/providers/xai_auth.rb +235 -13
- data/lib/zer0_image_generator/runner.rb +11 -9
- data/lib/zer0_image_generator/version.rb +1 -1
- metadata +3 -1
|
@@ -120,12 +120,13 @@ module Zer0ImageGenerator
|
|
|
120
120
|
# cross-contaminate concurrent renders.
|
|
121
121
|
class RunContext
|
|
122
122
|
attr_reader :project_root, :env
|
|
123
|
-
attr_accessor :anthropic, :slug, :article
|
|
123
|
+
attr_accessor :anthropic, :grok, :slug, :article
|
|
124
124
|
|
|
125
|
-
def initialize(project_root:, env:, anthropic: nil, slug: "", article: nil)
|
|
125
|
+
def initialize(project_root:, env:, anthropic: nil, grok: nil, slug: "", article: nil)
|
|
126
126
|
@project_root = project_root
|
|
127
127
|
@env = env
|
|
128
128
|
@anthropic = anthropic
|
|
129
|
+
@grok = grok
|
|
129
130
|
@slug = slug
|
|
130
131
|
# The page's own context (slug/section/title/categories/description/body),
|
|
131
132
|
# set per file by the runner. The local renderer derives BOTH composition
|
|
@@ -139,6 +140,14 @@ module Zer0ImageGenerator
|
|
|
139
140
|
@anthropic ||= AnthropicClient.new(@env)
|
|
140
141
|
end
|
|
141
142
|
|
|
143
|
+
def grok_client
|
|
144
|
+
@grok ||= GrokClient.new(@env)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def orchestrator(engine)
|
|
148
|
+
engine.to_s == "xai" ? grok_client : claude
|
|
149
|
+
end
|
|
150
|
+
|
|
142
151
|
# Per-file copy carrying a fresh slug (the runner calls `ctx.with_slug(slug)`
|
|
143
152
|
# once per worker). project_root/env/anthropic are shared by reference on
|
|
144
153
|
# purpose: env is read-only and the AnthropicClient is stateless after init,
|
|
@@ -146,7 +155,7 @@ module Zer0ImageGenerator
|
|
|
146
155
|
# and therefore must not be shared.
|
|
147
156
|
def with_slug(slug, article: nil)
|
|
148
157
|
RunContext.new(project_root: @project_root, env: @env,
|
|
149
|
-
anthropic: @anthropic, slug: slug, article: article)
|
|
158
|
+
anthropic: @anthropic, grok: @grok, slug: slug, article: article)
|
|
150
159
|
end
|
|
151
160
|
end
|
|
152
161
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../constants"
|
|
3
4
|
require_relative "base"
|
|
4
5
|
|
|
5
6
|
module Zer0ImageGenerator
|
|
@@ -61,8 +62,9 @@ module Zer0ImageGenerator
|
|
|
61
62
|
}
|
|
62
63
|
# gpt-image-2 does not accept input_fidelity (historical behavior).
|
|
63
64
|
fields["input_fidelity"] = settings.enhance_fidelity if model != "gpt-image-2"
|
|
65
|
+
image_bytes = File.binread(image_path.to_s)
|
|
64
66
|
files = [["image[]", image_path.basename.to_s,
|
|
65
|
-
|
|
67
|
+
image_bytes, Zer0ImageGenerator.image_media_type(image_bytes)]]
|
|
66
68
|
out_path ||= image_path
|
|
67
69
|
Logging.debug(
|
|
68
70
|
"OpenAI edit: model=#{model} fidelity=" \
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../constants"
|
|
3
4
|
require_relative "base"
|
|
4
5
|
require_relative "xai_auth"
|
|
5
6
|
|
|
@@ -39,6 +40,18 @@ module Zer0ImageGenerator
|
|
|
39
40
|
"grok-2-image"
|
|
40
41
|
end
|
|
41
42
|
|
|
43
|
+
# Imagine answers JPEG: keep the bytes (a PNG re-encode is ~4x larger) and
|
|
44
|
+
# give the file the extension they are, so it is served as what it is.
|
|
45
|
+
def named_for_its_bytes(out_path)
|
|
46
|
+
head = File.open(out_path.to_s, "rb") { |handle| handle.read(12) }
|
|
47
|
+
return ImageResult.new(true, "png", out_path) unless
|
|
48
|
+
Zer0ImageGenerator.image_media_type(head) == "image/jpeg"
|
|
49
|
+
|
|
50
|
+
jpg_path = out_path.sub_ext(".jpg")
|
|
51
|
+
File.rename(out_path.to_s, jpg_path.to_s)
|
|
52
|
+
ImageResult.new(true, "jpg", jpg_path)
|
|
53
|
+
end
|
|
54
|
+
|
|
42
55
|
def generate(prompt, settings, out_base, ctx)
|
|
43
56
|
credentials = XAIAuth.chain(ctx.env)
|
|
44
57
|
return ImageResult.new(false, error: XAIAuth::MISSING_HINT) if credentials.empty?
|
|
@@ -46,9 +59,22 @@ module Zer0ImageGenerator
|
|
|
46
59
|
model = Zer0ImageGenerator.effective_model(settings, self)
|
|
47
60
|
out_path = out_base.sub_ext(".png")
|
|
48
61
|
payload = { "model" => model, "prompt" => prompt[0, PROMPT_LIMIT], "n" => 1 }
|
|
62
|
+
aspect = XAIAuth.aspect_ratio(model, settings.size)
|
|
63
|
+
payload["aspect_ratio"] = aspect if aspect
|
|
49
64
|
url = "#{XAIAuth.base_url(ctx.env)}/images/generations"
|
|
50
65
|
|
|
51
66
|
credentials.each_with_index do |credential, index|
|
|
67
|
+
begin
|
|
68
|
+
credential = XAIAuth.ready_credential(credential)
|
|
69
|
+
rescue StandardError => exc
|
|
70
|
+
fallback = credentials[index + 1]
|
|
71
|
+
if fallback
|
|
72
|
+
Logging.warn("#{exc.message} — retrying with #{fallback.source}")
|
|
73
|
+
next
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
return ImageResult.new(false, error: "#{exc.message}; re-run your Grok login")
|
|
77
|
+
end
|
|
52
78
|
Logging.debug("xAI generate: model=#{model} auth=#{credential.source}")
|
|
53
79
|
begin
|
|
54
80
|
data = Http.with_retries("xAI API") do
|
|
@@ -59,7 +85,7 @@ module Zer0ImageGenerator
|
|
|
59
85
|
return ImageResult.new(false, error: "No image data in xAI response")
|
|
60
86
|
end
|
|
61
87
|
|
|
62
|
-
return
|
|
88
|
+
return named_for_its_bytes(out_path)
|
|
63
89
|
rescue HttpStatusError => exc
|
|
64
90
|
fallback = credentials[index + 1]
|
|
65
91
|
if AUTH_FAILURE_STATUSES.include?(exc.status) && fallback
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "time"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
require_relative "../http"
|
|
5
8
|
|
|
6
9
|
# xAI (Grok) credential resolution, shared by the XAIProvider.
|
|
7
10
|
#
|
|
@@ -10,23 +13,28 @@ require "time"
|
|
|
10
13
|
# console API key is where the value comes from and what we tell the human when
|
|
11
14
|
# it is rejected. This file owns that chain; the provider owns the request.
|
|
12
15
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
16
|
+
# Refresh, not login. The engine never MINTS a token — that takes an interactive
|
|
17
|
+
# login — but an expired token whose store also holds a refresh
|
|
18
|
+
# token and a client id (Kilo Code's login does) is refreshed at auth.x.ai once,
|
|
19
|
+
# right before a request needs it, and written back to that store so the login
|
|
20
|
+
# tool and the next run both see the rotated pair (lifehacker.dev's cover-art
|
|
21
|
+
# script already refreshes and saves the same way). Expired with nothing to
|
|
22
|
+
# refresh with, a token is treated as absent and the chain falls back to the
|
|
23
|
+
# API key.
|
|
19
24
|
module Zer0ImageGenerator
|
|
20
25
|
# One resolved way to authenticate against api.x.ai.
|
|
21
26
|
class XAICredential
|
|
22
|
-
attr_reader :mode, :token, :source
|
|
27
|
+
attr_reader :mode, :token, :source, :refresh_path
|
|
23
28
|
|
|
24
29
|
# mode: "oauth" | "api_key" — which rung of the chain this is.
|
|
25
30
|
# source: human label for logs and fallback warnings.
|
|
26
|
-
|
|
31
|
+
# refresh_path: set when the token has expired but its store can refresh
|
|
32
|
+
# it — the token is empty until XAIAuth.ready_credential refreshes it.
|
|
33
|
+
def initialize(mode, token, source, refresh_path = nil)
|
|
27
34
|
@mode = mode
|
|
28
35
|
@token = token
|
|
29
36
|
@source = source
|
|
37
|
+
@refresh_path = refresh_path
|
|
30
38
|
end
|
|
31
39
|
|
|
32
40
|
def headers
|
|
@@ -50,7 +58,8 @@ module Zer0ImageGenerator
|
|
|
50
58
|
# Where a Grok login drops its token when no path is configured. Relative to
|
|
51
59
|
# the home directory so a test (and a container) can point HOME elsewhere.
|
|
52
60
|
DEFAULT_CREDENTIALS_FILES = [
|
|
53
|
-
".grok/auth.json", ".config/grok/auth.json", ".xai/auth.json"
|
|
61
|
+
".grok/auth.json", ".config/grok/auth.json", ".xai/auth.json",
|
|
62
|
+
".local/share/kilo/auth.json"
|
|
54
63
|
].freeze
|
|
55
64
|
|
|
56
65
|
# Credential files nest the token differently per tool; look at the top
|
|
@@ -67,6 +76,17 @@ module Zer0ImageGenerator
|
|
|
67
76
|
AUTH_MODE_ENV = "XAI_AUTH"
|
|
68
77
|
AUTH_MODES = %w[auto oauth api_key].freeze
|
|
69
78
|
|
|
79
|
+
# The refresh grant's endpoint, as auth.x.ai's OIDC discovery document
|
|
80
|
+
# (/.well-known/openid-configuration) publishes it.
|
|
81
|
+
TOKEN_ENDPOINT = "https://auth.x.ai/oauth2/token"
|
|
82
|
+
REFRESH_TOKEN_KEYS = %w[refresh_token refresh].freeze
|
|
83
|
+
EXPIRY_KEYS = %w[expires_at expiry expiration expires].freeze
|
|
84
|
+
ISSUED_KEYS = %w[created_at obtained_at issued_at issued].freeze
|
|
85
|
+
# Workers share one store: the first to find the token expired refreshes
|
|
86
|
+
# it, the rest wait and read the result. A refresh token may rotate on use,
|
|
87
|
+
# so two concurrent refreshes would leave one worker holding a dead token.
|
|
88
|
+
REFRESH_LOCK = Mutex.new
|
|
89
|
+
|
|
70
90
|
MISSING_HINT =
|
|
71
91
|
"The xAI provider needs a Grok OAuth token or an xAI API key — any ONE of:\n" \
|
|
72
92
|
" 1. XAI_OAUTH_TOKEN (or GROK_OAUTH_TOKEN) — an OAuth access token from a\n" \
|
|
@@ -148,7 +168,10 @@ module Zer0ImageGenerator
|
|
|
148
168
|
home = home_dir(env)
|
|
149
169
|
return [] if home.nil? || home.empty?
|
|
150
170
|
|
|
151
|
-
DEFAULT_CREDENTIALS_FILES.map { |rel| File.join(home, rel) }
|
|
171
|
+
paths = DEFAULT_CREDENTIALS_FILES.map { |rel| File.join(home, rel) }
|
|
172
|
+
kilo = env["KILO_AUTH_PATH"]
|
|
173
|
+
paths.unshift(kilo) unless kilo.nil? || kilo.empty?
|
|
174
|
+
paths
|
|
152
175
|
end
|
|
153
176
|
|
|
154
177
|
# The env snapshot is the source of truth (tests and containers set HOME);
|
|
@@ -182,7 +205,13 @@ module Zer0ImageGenerator
|
|
|
182
205
|
return nil
|
|
183
206
|
end
|
|
184
207
|
if expired?(node)
|
|
185
|
-
|
|
208
|
+
if refresh_token(node) && client_id(node)
|
|
209
|
+
return XAICredential.new("oauth", "",
|
|
210
|
+
"Grok OAuth token (#{path}; expired, refreshed on use)",
|
|
211
|
+
path)
|
|
212
|
+
end
|
|
213
|
+
debug("xAI OAuth: the token in #{path} has expired and holds nothing to " \
|
|
214
|
+
"refresh it with; re-run your Grok login")
|
|
186
215
|
return nil
|
|
187
216
|
end
|
|
188
217
|
|
|
@@ -196,14 +225,182 @@ module Zer0ImageGenerator
|
|
|
196
225
|
nil
|
|
197
226
|
end
|
|
198
227
|
|
|
228
|
+
# The credential ready to send: itself, or for an expired OAuth token its
|
|
229
|
+
# store can refresh, the refreshed one. Raises RuntimeError on a failed refresh.
|
|
230
|
+
def ready_credential(credential)
|
|
231
|
+
return credential if credential.refresh_path.nil?
|
|
232
|
+
|
|
233
|
+
refresh_file_credential(credential.refresh_path)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def refresh_file_credential(path)
|
|
237
|
+
REFRESH_LOCK.synchronize do
|
|
238
|
+
begin
|
|
239
|
+
data = JSON.parse(File.read(path, encoding: "UTF-8"))
|
|
240
|
+
rescue JSON::ParserError, SystemCallError, IOError => exc
|
|
241
|
+
raise "xAI OAuth: cannot re-read #{path} (#{exc.message})"
|
|
242
|
+
end
|
|
243
|
+
node = data.is_a?(Hash) ? find_token_node(data) : nil
|
|
244
|
+
raise "xAI OAuth: no access token left in #{path}" if node.nil?
|
|
245
|
+
|
|
246
|
+
# Another worker may have refreshed the store while this one waited.
|
|
247
|
+
current = token_node(data)
|
|
248
|
+
if current && !expired?(current)
|
|
249
|
+
return XAICredential.new("oauth", current["access_token"].to_s,
|
|
250
|
+
"Grok OAuth token (#{path})")
|
|
251
|
+
end
|
|
252
|
+
refresh = refresh_token(node)
|
|
253
|
+
cid = client_id(node)
|
|
254
|
+
raise "xAI OAuth: the token in #{path} cannot be refreshed" if refresh.nil? || cid.nil?
|
|
255
|
+
|
|
256
|
+
fresh = request_refresh(refresh, cid)
|
|
257
|
+
store_refreshed(node, fresh, Time.now.to_f)
|
|
258
|
+
begin
|
|
259
|
+
write_store(path, data)
|
|
260
|
+
rescue SystemCallError, IOError => exc
|
|
261
|
+
# The refresh token may already have rotated, so say so loudly — but
|
|
262
|
+
# this run still holds a working access token.
|
|
263
|
+
warn_log("xAI OAuth: refreshed, but could not save the new token to #{path} " \
|
|
264
|
+
"(#{exc.message}); the login tool may need a fresh sign-in")
|
|
265
|
+
end
|
|
266
|
+
debug("xAI OAuth: refreshed the token in #{path}")
|
|
267
|
+
XAICredential.new("oauth", fresh["access_token"].to_s,
|
|
268
|
+
"Grok OAuth token (#{path}; refreshed)")
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# POST the refresh grant. Never retried: a refresh token that rotated on a
|
|
273
|
+
# request whose response was lost is already spent.
|
|
274
|
+
def request_refresh(refresh_token, client_id)
|
|
275
|
+
body = URI.encode_www_form("grant_type" => "refresh_token",
|
|
276
|
+
"refresh_token" => refresh_token,
|
|
277
|
+
"client_id" => client_id)
|
|
278
|
+
headers = { "Content-Type" => "application/x-www-form-urlencoded",
|
|
279
|
+
"Accept" => "application/json" }
|
|
280
|
+
begin
|
|
281
|
+
_, _, raw = Http.request(TOKEN_ENDPOINT, method: "POST", headers: headers,
|
|
282
|
+
data: body, timeout: 60)
|
|
283
|
+
rescue HttpStatusError => exc
|
|
284
|
+
# The OAuth error code (invalid_grant, invalid_client) is safe to show
|
|
285
|
+
# and says whether a re-login is due; the description may echo input.
|
|
286
|
+
details = exc.json
|
|
287
|
+
code = details.is_a?(Hash) ? details["error"] : nil
|
|
288
|
+
reason = token_string?(code) ? "HTTP #{exc.status}: #{code}" : "HTTP #{exc.status}"
|
|
289
|
+
raise "xAI OAuth refresh failed (#{reason})"
|
|
290
|
+
end
|
|
291
|
+
begin
|
|
292
|
+
data = JSON.parse(raw.to_s.dup.force_encoding("UTF-8"))
|
|
293
|
+
rescue JSON::ParserError, EncodingError
|
|
294
|
+
raise "xAI OAuth refresh returned non-JSON"
|
|
295
|
+
end
|
|
296
|
+
unless data.is_a?(Hash) && token_string?(data["access_token"])
|
|
297
|
+
raise "xAI OAuth refresh returned no access_token"
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
data
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Write the refreshed pair into the store's own node, in its own key names
|
|
304
|
+
# and time units, so the tool that owns the file still reads it.
|
|
305
|
+
def store_refreshed(node, fresh, now)
|
|
306
|
+
token_key = token_string?(node["access_token"]) ? "access_token" : "access"
|
|
307
|
+
node[token_key] = fresh["access_token"]
|
|
308
|
+
if token_string?(fresh["refresh_token"])
|
|
309
|
+
refresh_key = REFRESH_TOKEN_KEYS.find { |key| token_string?(node[key]) } || "refresh_token"
|
|
310
|
+
node[refresh_key] = fresh["refresh_token"]
|
|
311
|
+
end
|
|
312
|
+
lifetime = number(fresh["expires_in"])
|
|
313
|
+
lifetime = 3600.0 if lifetime.nil? || lifetime.zero?
|
|
314
|
+
EXPIRY_KEYS.each do |key|
|
|
315
|
+
next if epoch_seconds(node[key]).nil?
|
|
316
|
+
|
|
317
|
+
node[key] = timestamp_like(node[key], now + lifetime)
|
|
318
|
+
return
|
|
319
|
+
end
|
|
320
|
+
return if number(node["expires_in"]).nil?
|
|
321
|
+
|
|
322
|
+
node["expires_in"] = lifetime.to_i
|
|
323
|
+
ISSUED_KEYS.each do |key|
|
|
324
|
+
next if epoch_seconds(node[key]).nil?
|
|
325
|
+
|
|
326
|
+
node[key] = timestamp_like(node[key], now)
|
|
327
|
+
return
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# `seconds` in the shape `previous` used: epoch ms, epoch s, or ISO-8601.
|
|
332
|
+
def timestamp_like(previous, seconds)
|
|
333
|
+
value = number(previous)
|
|
334
|
+
unless value.nil?
|
|
335
|
+
stamp = value > 1e11 ? (seconds * 1000).to_i : seconds.to_i
|
|
336
|
+
return previous.is_a?(String) ? stamp.to_s : stamp
|
|
337
|
+
end
|
|
338
|
+
Time.at(seconds.to_i).utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Replace the store atomically, keeping its permissions (it holds secrets).
|
|
342
|
+
def write_store(path, data)
|
|
343
|
+
target = File.realpath(path)
|
|
344
|
+
mode = File.stat(target).mode & 0o777
|
|
345
|
+
tmp = File.join(File.dirname(target), ".#{File.basename(target)}.#{Process.pid}.tmp")
|
|
346
|
+
begin
|
|
347
|
+
# Created owner-only: the token must never sit world-readable, even briefly.
|
|
348
|
+
File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |handle|
|
|
349
|
+
handle.write("#{JSON.pretty_generate(data)}\n")
|
|
350
|
+
end
|
|
351
|
+
File.chmod(mode, tmp)
|
|
352
|
+
File.rename(tmp, target)
|
|
353
|
+
ensure
|
|
354
|
+
File.delete(tmp) if File.exist?(tmp)
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def refresh_token(node)
|
|
359
|
+
REFRESH_TOKEN_KEYS.each { |key| return node[key] if token_string?(node[key]) }
|
|
360
|
+
nil
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# The OAuth client the token was issued to: the store's own record, else
|
|
364
|
+
# the access token's JWT claims (client_id, then a string or first aud).
|
|
365
|
+
def client_id(node)
|
|
366
|
+
return node["client_id"] if token_string?(node["client_id"])
|
|
367
|
+
|
|
368
|
+
access = token_string?(node["access_token"]) ? node["access_token"] : node["access"]
|
|
369
|
+
claims = jwt_claims(access)
|
|
370
|
+
return claims["client_id"] if token_string?(claims["client_id"])
|
|
371
|
+
|
|
372
|
+
aud = claims["aud"]
|
|
373
|
+
aud = aud.find { |entry| token_string?(entry) } if aud.is_a?(Array)
|
|
374
|
+
token_string?(aud) ? aud : nil
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def jwt_claims(token)
|
|
378
|
+
parts = token.is_a?(String) ? token.split(".") : []
|
|
379
|
+
return {} if parts.length < 2
|
|
380
|
+
|
|
381
|
+
claims = JSON.parse(parts[1].tr("-_", "+/").unpack1("m").force_encoding("UTF-8"))
|
|
382
|
+
claims.is_a?(Hash) ? claims : {}
|
|
383
|
+
rescue JSON::ParserError, ArgumentError, EncodingError
|
|
384
|
+
{}
|
|
385
|
+
end
|
|
386
|
+
|
|
199
387
|
# The hash actually holding `access_token`: the document itself, or one of
|
|
200
388
|
# the containers a login tool nests it under.
|
|
201
389
|
def token_node(data)
|
|
202
|
-
|
|
390
|
+
node = find_token_node(data)
|
|
391
|
+
return nil if node.nil?
|
|
392
|
+
return node if token_string?(node["access_token"])
|
|
393
|
+
|
|
394
|
+
node.merge("access_token" => node["access"])
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def find_token_node(data)
|
|
398
|
+
return data if token_string?(data["access_token"]) || token_string?(data["access"])
|
|
203
399
|
|
|
204
400
|
NESTED_KEYS.each do |key|
|
|
205
401
|
nested = data[key]
|
|
206
|
-
|
|
402
|
+
next unless nested.is_a?(Hash)
|
|
403
|
+
return nested if token_string?(nested["access_token"]) || token_string?(nested["access"])
|
|
207
404
|
end
|
|
208
405
|
nil
|
|
209
406
|
end
|
|
@@ -250,6 +447,27 @@ module Zer0ImageGenerator
|
|
|
250
447
|
end
|
|
251
448
|
end
|
|
252
449
|
|
|
450
|
+
# Aspect ratios the Imagine models take. grok-imagine renders a square
|
|
451
|
+
# unless told otherwise, which a wide banner then crops; grok-2-image
|
|
452
|
+
# predates the parameter, so it is sent nothing.
|
|
453
|
+
IMAGINE_PREFIX = "grok-imagine"
|
|
454
|
+
ASPECT_RATIOS = [
|
|
455
|
+
["1:1", 1.0], ["3:2", 1.5], ["2:3", 2.0 / 3], ["4:3", 4.0 / 3],
|
|
456
|
+
["3:4", 0.75], ["16:9", 16.0 / 9], ["9:16", 9.0 / 16]
|
|
457
|
+
].freeze
|
|
458
|
+
|
|
459
|
+
# The Imagine aspect ratio nearest the configured WIDTHxHEIGHT size, or nil
|
|
460
|
+
# for a non-Imagine model or a size with no dimensions (e.g. `auto`).
|
|
461
|
+
def aspect_ratio(model, size)
|
|
462
|
+
return nil unless (model || "").to_s.strip.downcase.start_with?(IMAGINE_PREFIX)
|
|
463
|
+
|
|
464
|
+
match = /\A\s*(\d+)\s*[xX]\s*(\d+)\s*\z/.match(size.to_s)
|
|
465
|
+
return nil if match.nil? || match[1].to_i.zero? || match[2].to_i.zero?
|
|
466
|
+
|
|
467
|
+
wanted = Math.log(match[1].to_f / match[2].to_i)
|
|
468
|
+
ASPECT_RATIOS.min_by { |(_label, ratio)| (Math.log(ratio) - wanted).abs }[0]
|
|
469
|
+
end
|
|
470
|
+
|
|
253
471
|
def number(raw)
|
|
254
472
|
return raw.to_f if raw.is_a?(Numeric)
|
|
255
473
|
return nil unless raw.is_a?(String) && !raw.empty?
|
|
@@ -266,5 +484,9 @@ module Zer0ImageGenerator
|
|
|
266
484
|
def debug(message)
|
|
267
485
|
Logging.debug(message) if defined?(Logging)
|
|
268
486
|
end
|
|
487
|
+
|
|
488
|
+
def warn_log(message)
|
|
489
|
+
Logging.warn(message) if defined?(Logging)
|
|
490
|
+
end
|
|
269
491
|
end
|
|
270
492
|
end
|
|
@@ -258,19 +258,20 @@ module Zer0ImageGenerator
|
|
|
258
258
|
base_prompt = @deps.build_prompt(cf, file_settings)
|
|
259
259
|
prompt = base_prompt
|
|
260
260
|
orchestrated = settings.provider != "local" # local is deterministic
|
|
261
|
-
if orchestrated && file_settings.prompt_engine
|
|
262
|
-
prompt = @deps.claude_article_brief(@ctx.
|
|
261
|
+
if orchestrated && %w[claude xai].include?(file_settings.prompt_engine) && !settings.dry_run
|
|
262
|
+
prompt = @deps.claude_article_brief(@ctx.orchestrator(file_settings.prompt_engine),
|
|
263
|
+
cf, file_settings, base_prompt)
|
|
263
264
|
end
|
|
264
265
|
debug("Generated prompt: #{prompt[0, 500]}...")
|
|
265
266
|
|
|
266
267
|
if settings.dry_run
|
|
267
268
|
info("[DRY RUN] Would generate image:")
|
|
268
269
|
say(" Provider: #{settings.provider}")
|
|
269
|
-
if orchestrated && file_settings.prompt_engine
|
|
270
|
-
say(" Prompt engine:
|
|
270
|
+
if orchestrated && %w[claude xai].include?(file_settings.prompt_engine)
|
|
271
|
+
say(" Prompt engine: #{file_settings.prompt_engine} (article analysis runs at generation time)")
|
|
271
272
|
end
|
|
272
|
-
if orchestrated && file_settings.review_engine
|
|
273
|
-
say(" Review:
|
|
273
|
+
if orchestrated && %w[claude xai].include?(file_settings.review_engine)
|
|
274
|
+
say(" Review: #{file_settings.review_engine} (image review runs at generation time)")
|
|
274
275
|
end
|
|
275
276
|
say(" Output: #{out_base.sub_ext('.png')}")
|
|
276
277
|
say(" Preview path: #{@deps.preview_front_matter_path(file_settings, slug + '.png')}")
|
|
@@ -291,10 +292,11 @@ module Zer0ImageGenerator
|
|
|
291
292
|
result = provider.generate(prompt, file_settings, out_base, file_ctx)
|
|
292
293
|
|
|
293
294
|
# ---- Review: Claude inspects the render; at most ONE regeneration ----
|
|
294
|
-
if orchestrated && file_settings.review_engine
|
|
295
|
-
result.ok && result.path && result.kind
|
|
295
|
+
if orchestrated && %w[claude xai].include?(file_settings.review_engine) &&
|
|
296
|
+
result.ok && result.path && %w[png jpg].include?(result.kind)
|
|
296
297
|
approved, critique, revised =
|
|
297
|
-
@deps.claude_review_image(@ctx.
|
|
298
|
+
@deps.claude_review_image(@ctx.orchestrator(file_settings.review_engine),
|
|
299
|
+
result.path, cf, prompt, file_settings)
|
|
298
300
|
if approved
|
|
299
301
|
debug("Claude review: #{critique}") if critique && !critique.empty?
|
|
300
302
|
else
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: zer0-image-generator
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Amr Abdel
|
|
@@ -58,8 +58,10 @@ files:
|
|
|
58
58
|
- lib/zer0_image_generator/constants.rb
|
|
59
59
|
- lib/zer0_image_generator/content.rb
|
|
60
60
|
- lib/zer0_image_generator/engine.rb
|
|
61
|
+
- lib/zer0_image_generator/facade.rb
|
|
61
62
|
- lib/zer0_image_generator/freesvg/cache.rb
|
|
62
63
|
- lib/zer0_image_generator/freesvg/client.rb
|
|
64
|
+
- lib/zer0_image_generator/grok/client.rb
|
|
63
65
|
- lib/zer0_image_generator/http.rb
|
|
64
66
|
- lib/zer0_image_generator/library.rb
|
|
65
67
|
- lib/zer0_image_generator/logging.rb
|