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,386 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Port of the settings layer of lib/zer0_image_generator/preview_generator.py:
|
|
4
|
+
# the Settings value object, resolve_settings' CLI>env>config>DEFAULTS
|
|
5
|
+
# precedence chain, and the per-file override machinery (author + collection
|
|
6
|
+
# style blocks). The Python file stays the oracle;
|
|
7
|
+
# test/ruby/test_settings.rb differential-tests resolve_settings against it.
|
|
8
|
+
|
|
9
|
+
require_relative "config"
|
|
10
|
+
|
|
11
|
+
module Zer0ImageGenerator
|
|
12
|
+
# Fully-resolved run settings (before per-file author overrides).
|
|
13
|
+
#
|
|
14
|
+
# Ports the Python `@dataclass Settings`. Field names and defaults match the
|
|
15
|
+
# oracle one-for-one so `to_h` diffs cleanly against `dataclasses.asdict`.
|
|
16
|
+
class Settings
|
|
17
|
+
ATTRIBUTES = %i[
|
|
18
|
+
provider model size quality style style_modifiers output_dir
|
|
19
|
+
assets_prefix auto_prefix enabled collections collections_dir
|
|
20
|
+
collection_styles front_matter_key authors_file prompt_engine
|
|
21
|
+
review_engine claude_model claude_effort dry_run verbose force
|
|
22
|
+
list_only parallel batch file collection enhance enhance_prompt
|
|
23
|
+
enhance_model enhance_quality enhance_fidelity enhance_format
|
|
24
|
+
rasterizer provider_explicit
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
attr_accessor(*ATTRIBUTES)
|
|
28
|
+
|
|
29
|
+
def initialize(**overrides)
|
|
30
|
+
@provider = DEFAULTS["provider"]
|
|
31
|
+
@model = DEFAULTS["model"]
|
|
32
|
+
@size = DEFAULTS["size"]
|
|
33
|
+
@quality = DEFAULTS["quality"]
|
|
34
|
+
@style = DEFAULTS["style"]
|
|
35
|
+
@style_modifiers = DEFAULTS["style_modifiers"]
|
|
36
|
+
@output_dir = DEFAULTS["output_dir"]
|
|
37
|
+
@assets_prefix = DEFAULTS["assets_prefix"]
|
|
38
|
+
@auto_prefix = DEFAULTS["auto_prefix"]
|
|
39
|
+
@enabled = true
|
|
40
|
+
# Fresh copies so two Settings never share the same mutable default
|
|
41
|
+
# (mirrors the dataclass field(default_factory=...)).
|
|
42
|
+
@collections = DEFAULTS["collections"].dup
|
|
43
|
+
@collections_dir = DEFAULTS["collections_dir"]
|
|
44
|
+
@collection_styles = {}
|
|
45
|
+
@front_matter_key = DEFAULTS["front_matter_key"]
|
|
46
|
+
@authors_file = DEFAULTS["authors_file"]
|
|
47
|
+
@prompt_engine = DEFAULTS["prompt_engine"]
|
|
48
|
+
@review_engine = DEFAULTS["review_engine"]
|
|
49
|
+
@claude_model = DEFAULTS["claude_model"]
|
|
50
|
+
@claude_effort = DEFAULTS["claude_effort"]
|
|
51
|
+
|
|
52
|
+
@dry_run = false
|
|
53
|
+
@verbose = false
|
|
54
|
+
@force = false
|
|
55
|
+
@list_only = false
|
|
56
|
+
@parallel = 4
|
|
57
|
+
@batch = 0
|
|
58
|
+
|
|
59
|
+
@file = ""
|
|
60
|
+
@collection = ""
|
|
61
|
+
|
|
62
|
+
@enhance = false
|
|
63
|
+
@enhance_prompt = ""
|
|
64
|
+
@enhance_model = ENHANCE_DEFAULTS["model"]
|
|
65
|
+
@enhance_quality = ENHANCE_DEFAULTS["quality"]
|
|
66
|
+
@enhance_fidelity = ENHANCE_DEFAULTS["fidelity"]
|
|
67
|
+
@enhance_format = ENHANCE_DEFAULTS["format"]
|
|
68
|
+
|
|
69
|
+
@rasterizer = "auto"
|
|
70
|
+
@provider_explicit = false
|
|
71
|
+
|
|
72
|
+
overrides.each { |key, value| public_send("#{key}=", value) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_h
|
|
76
|
+
ATTRIBUTES.each_with_object({}) { |attr, acc| acc[attr] = public_send(attr) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Port of dataclasses.replace(settings, **changes): a copy with `changes`
|
|
80
|
+
# applied. Accepts string or symbol keys (OVERRIDE_KEYS are strings).
|
|
81
|
+
def dup_with(changes)
|
|
82
|
+
copy = dup
|
|
83
|
+
copy.instance_variable_set(:@collections, @collections.dup)
|
|
84
|
+
copy.instance_variable_set(:@collection_styles, @collection_styles.dup)
|
|
85
|
+
changes.each { |key, value| copy.public_send("#{key}=", value) }
|
|
86
|
+
copy
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def ==(other)
|
|
90
|
+
other.is_a?(Settings) && to_h == other.to_h
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Mirrors the argparse.Namespace resolve_settings consumes. A CLI builds one
|
|
95
|
+
# from parsed flags; a Rails controller builds one from request params. Field
|
|
96
|
+
# names and defaults match build_arg_parser's `dest=` / `default=`.
|
|
97
|
+
#
|
|
98
|
+
# Build with a Hash (string or symbol keys); `Args.build` applies argparse
|
|
99
|
+
# defaults for anything omitted and reproduces parse_args' post-processing
|
|
100
|
+
# (the historical rule that --enhance-prompt / -fidelity / -format imply
|
|
101
|
+
# --enhance) so a Rails caller behaves identically to the CLI.
|
|
102
|
+
class Args
|
|
103
|
+
DEFAULTS = {
|
|
104
|
+
dry_run: false, verbose: false, file: nil, collection: nil,
|
|
105
|
+
provider: nil, model: nil, output_dir: nil, force: false,
|
|
106
|
+
list_missing: false, parallel: nil, enhance: false,
|
|
107
|
+
enhance_prompt: nil, enhance_model: nil, enhance_quality: nil,
|
|
108
|
+
enhance_fidelity: nil, enhance_format: nil, prompt_engine: nil,
|
|
109
|
+
review: nil, rasterizer: nil, style: nil, assets_prefix: nil,
|
|
110
|
+
no_auto_prefix: false, collections_dir: nil, front_matter_key: nil,
|
|
111
|
+
authors_file: nil, batch: 0, log_file: nil, rate_limit: nil
|
|
112
|
+
}.freeze
|
|
113
|
+
|
|
114
|
+
attr_accessor(*DEFAULTS.keys)
|
|
115
|
+
|
|
116
|
+
def self.build(source = {})
|
|
117
|
+
return source if source.is_a?(Args)
|
|
118
|
+
|
|
119
|
+
normalized = {}
|
|
120
|
+
(source.respond_to?(:to_h) ? source.to_h : source).each do |key, value|
|
|
121
|
+
normalized[key.to_sym] = value
|
|
122
|
+
end
|
|
123
|
+
new(normalized).tap(&:apply_enhance_implication!)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def initialize(overrides = {})
|
|
127
|
+
DEFAULTS.each { |key, value| instance_variable_set("@#{key}", value) }
|
|
128
|
+
overrides.each do |key, value|
|
|
129
|
+
sym = key.to_sym
|
|
130
|
+
# Unknown keys would silently vanish; surface them instead of masking a
|
|
131
|
+
# caller typo (e.g. :front_matter vs :front_matter_key).
|
|
132
|
+
raise ArgumentError, "unknown Args field: #{key}" unless DEFAULTS.key?(sym)
|
|
133
|
+
|
|
134
|
+
instance_variable_set("@#{sym}", value)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# parse_args post-processing: these three flags imply --enhance, but
|
|
139
|
+
# --enhance-model deliberately does not (historical behavior).
|
|
140
|
+
def apply_enhance_implication!
|
|
141
|
+
@enhance = true if @enhance_prompt || @enhance_fidelity || @enhance_format
|
|
142
|
+
self
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
module_function
|
|
147
|
+
|
|
148
|
+
# Settings fields an override block may replace per file (see constants.rb's
|
|
149
|
+
# OVERRIDE_KEYS). Keep only recognized keys with non-empty stringified values.
|
|
150
|
+
def _filter_override_block(block)
|
|
151
|
+
return {} unless block.is_a?(Hash)
|
|
152
|
+
|
|
153
|
+
result = {}
|
|
154
|
+
block.each do |key, value|
|
|
155
|
+
next unless OVERRIDE_KEYS.include?(key)
|
|
156
|
+
next if value.nil?
|
|
157
|
+
|
|
158
|
+
# python_str, not to_s: a boolean override value must stringify as
|
|
159
|
+
# "True"/"False" (CPython str()) to stay byte-identical to the oracle.
|
|
160
|
+
stripped = python_str(value).strip
|
|
161
|
+
result[key] = stripped unless stripped.empty?
|
|
162
|
+
end
|
|
163
|
+
result
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# `preview:` override block for an author key. `author:` may be a list or
|
|
167
|
+
# mapping in some posts — only a plain string key can index authors.yml.
|
|
168
|
+
def author_preview_overrides(authors, author_key)
|
|
169
|
+
return {} unless author_key.is_a?(String) && !author_key.empty?
|
|
170
|
+
|
|
171
|
+
author = authors[author_key]
|
|
172
|
+
return {} unless author.is_a?(Hash)
|
|
173
|
+
|
|
174
|
+
_filter_override_block(author["preview"])
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The file's collection, from the nearest `_<name>` ancestor directory
|
|
178
|
+
# (Jekyll's collection layout) — "" for files outside any collection.
|
|
179
|
+
def collection_of(path)
|
|
180
|
+
Pathname.new(path.to_s).expand_path.parent.ascend do |ancestor|
|
|
181
|
+
name = ancestor.basename.to_s
|
|
182
|
+
return name[1..] if name.start_with?("_") && name.length > 1
|
|
183
|
+
end
|
|
184
|
+
""
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The file's section — the directory immediately INSIDE its collection
|
|
188
|
+
# (`pages/_posts/hacks/x.md` -> 'hacks') — or "" when the collection holds its
|
|
189
|
+
# files flat. Sites that fold several kinds of writing into one collection
|
|
190
|
+
# carry the editorial distinction here, and it is the strongest single signal
|
|
191
|
+
# for what a banner should look like.
|
|
192
|
+
def section_of(path)
|
|
193
|
+
parts = Pathname.new(path.to_s).expand_path.to_s.split(File::SEPARATOR)
|
|
194
|
+
parts.each_with_index do |name, i|
|
|
195
|
+
next unless name.start_with?("_") && name.length > 1
|
|
196
|
+
|
|
197
|
+
# parts[i + 1] is a directory only when something follows it.
|
|
198
|
+
return i + 2 < parts.length ? parts[i + 1] : ""
|
|
199
|
+
end
|
|
200
|
+
""
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The page context the local renderer derives its art from.
|
|
204
|
+
def article_art_context(content_file, slug)
|
|
205
|
+
{
|
|
206
|
+
"slug" => slug,
|
|
207
|
+
"section" => (s = section_of(content_file.path)).empty? ? collection_of(content_file.path) : s,
|
|
208
|
+
"title" => content_file.title.to_s,
|
|
209
|
+
"description" => content_file.description.to_s,
|
|
210
|
+
"categories" => content_file.categories.to_s,
|
|
211
|
+
"body" => content_file.content.to_s,
|
|
212
|
+
}
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# `collection_styles:` override block for a collection — same keys as author
|
|
216
|
+
# overrides, so each collection can carry its own visual theme.
|
|
217
|
+
def collection_preview_overrides(collection_styles, collection)
|
|
218
|
+
return {} if collection.nil? || collection.empty? || !collection_styles.is_a?(Hash)
|
|
219
|
+
|
|
220
|
+
_filter_override_block(collection_styles[collection])
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Merge CLI > env > _config.yml `preview_images:` > DEFAULTS into a Settings.
|
|
224
|
+
# `config` is the site's FULL _config.yml Hash — the block is extracted here,
|
|
225
|
+
# and two top-level Jekyll keys are honored as fallbacks: collections_dir and
|
|
226
|
+
# (for `collections: auto`) the collections: map.
|
|
227
|
+
#
|
|
228
|
+
# `args` is an Args (or any Hash/Struct Args.build accepts).
|
|
229
|
+
def resolve_settings(args, config)
|
|
230
|
+
args = Args.build(args)
|
|
231
|
+
|
|
232
|
+
site = config["preview_images"]
|
|
233
|
+
site = {} unless site.is_a?(Hash)
|
|
234
|
+
|
|
235
|
+
cfg = lambda do |key, default|
|
|
236
|
+
value = site[key]
|
|
237
|
+
value.nil? ? default : value
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# env only wins when the CLI value is nil AND the env value is non-empty.
|
|
241
|
+
pick = lambda do |cli_value, env_name, cfg_key|
|
|
242
|
+
return cli_value unless cli_value.nil?
|
|
243
|
+
|
|
244
|
+
env_value = ENV[env_name]
|
|
245
|
+
return env_value if !env_value.nil? && !env_value.empty?
|
|
246
|
+
|
|
247
|
+
python_str(cfg.call(cfg_key, DEFAULTS[cfg_key]))
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# collections_dir: block override > Jekyll's own top-level key > default.
|
|
251
|
+
collections_dir = cfg.call(
|
|
252
|
+
"collections_dir",
|
|
253
|
+
python_first_truthy(config["collections_dir"], DEFAULTS["collections_dir"])
|
|
254
|
+
)
|
|
255
|
+
collections_dir = args.collections_dir unless args.collections_dir.nil?
|
|
256
|
+
|
|
257
|
+
collections = cfg.call("collections", DEFAULTS["collections"])
|
|
258
|
+
if collections == "auto" || (collections.is_a?(Array) && collections.include?("auto"))
|
|
259
|
+
# Discover from Jekyll's own collections: map; posts always exists.
|
|
260
|
+
declared = config["collections"]
|
|
261
|
+
names = declared.is_a?(Hash) ? declared.keys : []
|
|
262
|
+
collections = ["posts"] + names.reject { |n| n == "posts" }
|
|
263
|
+
end
|
|
264
|
+
collections = DEFAULTS["collections"].dup unless collections.is_a?(Array) && !collections.empty?
|
|
265
|
+
|
|
266
|
+
parallel_env = ENV.fetch("MAX_PARALLEL", "")
|
|
267
|
+
parallel = if !args.parallel.nil?
|
|
268
|
+
args.parallel
|
|
269
|
+
elsif parallel_env.match?(/\A[0-9]+\z/)
|
|
270
|
+
Integer(parallel_env, 10)
|
|
271
|
+
else
|
|
272
|
+
4
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
collection_styles = cfg.call("collection_styles", {})
|
|
276
|
+
collection_styles = {} unless collection_styles.is_a?(Hash)
|
|
277
|
+
|
|
278
|
+
Settings.new(
|
|
279
|
+
provider: pick.call(args.provider, "AI_PROVIDER", "provider"),
|
|
280
|
+
model: pick.call(args.model, "IMAGE_MODEL", "model"),
|
|
281
|
+
size: pick.call(nil, "IMAGE_SIZE", "size"),
|
|
282
|
+
quality: pick.call(nil, "IMAGE_QUALITY", "quality"),
|
|
283
|
+
style: pick.call(args.style, "IMAGE_STYLE", "style"),
|
|
284
|
+
style_modifiers: pick.call(nil, "IMAGE_STYLE_MODIFIERS", "style_modifiers"),
|
|
285
|
+
output_dir: pick.call(args.output_dir, "OUTPUT_DIR", "output_dir"),
|
|
286
|
+
assets_prefix: (
|
|
287
|
+
args.assets_prefix.nil? ? python_str(cfg.call("assets_prefix", DEFAULTS["assets_prefix"])) : args.assets_prefix
|
|
288
|
+
),
|
|
289
|
+
auto_prefix: (
|
|
290
|
+
args.no_auto_prefix ? false : python_bool(cfg.call("auto_prefix", DEFAULTS["auto_prefix"]))
|
|
291
|
+
),
|
|
292
|
+
enabled: python_bool(cfg.call("enabled", true)),
|
|
293
|
+
collections: collections.map { |c| python_str(c) },
|
|
294
|
+
collection_styles: collection_styles,
|
|
295
|
+
collections_dir: python_str(python_first_truthy(collections_dir, "")).strip.gsub(%r{\A/+|/+\z}, ""),
|
|
296
|
+
front_matter_key: python_first_truthy(
|
|
297
|
+
args.front_matter_key,
|
|
298
|
+
python_str(cfg.call("front_matter_key", DEFAULTS["front_matter_key"])).strip,
|
|
299
|
+
DEFAULTS["front_matter_key"]
|
|
300
|
+
),
|
|
301
|
+
authors_file: (
|
|
302
|
+
if args.authors_file.nil?
|
|
303
|
+
python_str(python_first_truthy(cfg.call("authors_file", DEFAULTS["authors_file"]), ""))
|
|
304
|
+
else
|
|
305
|
+
args.authors_file
|
|
306
|
+
end
|
|
307
|
+
),
|
|
308
|
+
prompt_engine: pick.call(args.prompt_engine, "PROMPT_ENGINE", "prompt_engine"),
|
|
309
|
+
review_engine: pick.call(args.review, "REVIEW_ENGINE", "review_engine"),
|
|
310
|
+
claude_model: python_str(python_first_truthy(cfg.call("claude_model", ""), "")),
|
|
311
|
+
claude_effort: python_str(python_first_truthy(cfg.call("claude_effort", DEFAULTS["claude_effort"]), "")),
|
|
312
|
+
dry_run: (args.dry_run ? true : _env_flag("DRY_RUN")),
|
|
313
|
+
verbose: (args.verbose ? true : _env_flag("VERBOSE")),
|
|
314
|
+
force: (args.force ? true : _env_flag("FORCE")),
|
|
315
|
+
list_only: (args.list_missing ? true : _env_flag("LIST_ONLY")),
|
|
316
|
+
parallel: parallel,
|
|
317
|
+
batch: python_first_truthy(args.batch, 0),
|
|
318
|
+
# These mirror bare `args.x or default` chains in the oracle (no str()
|
|
319
|
+
# wrapper), so values pass through uncoerced exactly as Python leaves them.
|
|
320
|
+
file: python_first_truthy(args.file, ""),
|
|
321
|
+
collection: python_first_truthy(args.collection, ""),
|
|
322
|
+
enhance: (args.enhance ? true : _env_flag("ENHANCE")),
|
|
323
|
+
enhance_prompt: python_first_truthy(args.enhance_prompt, ""),
|
|
324
|
+
enhance_model: python_first_truthy(args.enhance_model, ENV["ENHANCE_MODEL"], ENHANCE_DEFAULTS["model"]),
|
|
325
|
+
enhance_quality: python_first_truthy(args.enhance_quality, ENV["ENHANCE_QUALITY"], ENHANCE_DEFAULTS["quality"]),
|
|
326
|
+
enhance_fidelity: python_first_truthy(args.enhance_fidelity, ENV["ENHANCE_FIDELITY"], ENHANCE_DEFAULTS["fidelity"]),
|
|
327
|
+
enhance_format: python_first_truthy(args.enhance_format, ENV["ENHANCE_FORMAT"], ENHANCE_DEFAULTS["format"]),
|
|
328
|
+
rasterizer: pick.call(args.rasterizer, "IMAGE_RASTERIZER", "rasterizer"),
|
|
329
|
+
provider_explicit: !args.provider.nil? || python_bool(ENV["AI_PROVIDER"])
|
|
330
|
+
)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Per-file settings copy with override blocks applied in order — later blocks
|
|
334
|
+
# win (pass collection styles first, author overrides on top). Returns the
|
|
335
|
+
# same object when nothing overrides, matching dataclasses.replace's identity
|
|
336
|
+
# short-circuit in the oracle.
|
|
337
|
+
def apply_preview_overrides(settings, *override_blocks)
|
|
338
|
+
merged = {}
|
|
339
|
+
override_blocks.each { |block| merged.update(block || {}) }
|
|
340
|
+
return settings if merged.empty?
|
|
341
|
+
|
|
342
|
+
changes = {}
|
|
343
|
+
OVERRIDE_KEYS.each do |key|
|
|
344
|
+
changes[key] = merged.key?(key) ? merged[key] : settings.public_send(key)
|
|
345
|
+
end
|
|
346
|
+
settings.dup_with(changes)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# The per-file settings: global → collection_styles[collection] → author
|
|
350
|
+
# preview: block (most specific wins). `cf` is any object exposing `path` and
|
|
351
|
+
# `author` (the ContentFile from the front-matter slice).
|
|
352
|
+
def file_preview_settings(settings, cf, authors)
|
|
353
|
+
coll_overrides = collection_preview_overrides(settings.collection_styles, collection_of(cf.path))
|
|
354
|
+
author_overrides = author_preview_overrides(authors, cf.author)
|
|
355
|
+
result = apply_preview_overrides(settings, coll_overrides, author_overrides)
|
|
356
|
+
info(" ↳ Collection '#{collection_of(cf.path)}' preview style applied") unless coll_overrides.empty?
|
|
357
|
+
unless author_overrides.empty?
|
|
358
|
+
info(" ↳ Author '#{cf.author}' preview overrides applied (#{settings.authors_file})")
|
|
359
|
+
end
|
|
360
|
+
result
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Model-name prefix → provider family, or nil. FIRST matching prefix wins, in
|
|
364
|
+
# MODEL_FAMILIES insertion order (see constants.rb).
|
|
365
|
+
def model_family(model)
|
|
366
|
+
m = (model || "").to_s.strip.downcase
|
|
367
|
+
MODEL_FAMILIES.each { |prefix, family| return family if m.start_with?(prefix) }
|
|
368
|
+
nil
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Configured model when it belongs to the provider's family, else the provider
|
|
372
|
+
# default (with a warning) — never emit a guaranteed-400 request. `provider`
|
|
373
|
+
# is any object exposing `name` and `default_model` (the Provider slice).
|
|
374
|
+
def effective_model(settings, provider)
|
|
375
|
+
model = (settings.model || "").to_s.strip
|
|
376
|
+
return provider.default_model if model.empty?
|
|
377
|
+
|
|
378
|
+
family = model_family(model)
|
|
379
|
+
if !family.nil? && family != provider.name
|
|
380
|
+
warn("Model '#{model}' belongs to the '#{family}' family; using " \
|
|
381
|
+
"#{provider.name} default '#{provider.default_model}' instead")
|
|
382
|
+
return provider.default_model
|
|
383
|
+
end
|
|
384
|
+
model
|
|
385
|
+
end
|
|
386
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zer0ImageGenerator
|
|
4
|
+
# Per-run counters. Mirrors the Python @dataclass Stats: worker threads share
|
|
5
|
+
# one instance and call inc(name), so the mutation must be guarded (Python
|
|
6
|
+
# relied on its own threading.Lock here, not just the GIL — inc reads then
|
|
7
|
+
# writes, which is not atomic).
|
|
8
|
+
class Stats
|
|
9
|
+
ATTRS = %i[processed generated enhanced skipped errors].freeze
|
|
10
|
+
|
|
11
|
+
attr_accessor(*ATTRS)
|
|
12
|
+
|
|
13
|
+
def initialize(processed: 0, generated: 0, enhanced: 0, skipped: 0, errors: 0)
|
|
14
|
+
@processed = processed
|
|
15
|
+
@generated = generated
|
|
16
|
+
@enhanced = enhanced
|
|
17
|
+
@skipped = skipped
|
|
18
|
+
@errors = errors
|
|
19
|
+
@lock = Mutex.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def inc(name)
|
|
23
|
+
key = name.to_sym
|
|
24
|
+
# Guard against a typo'd counter name silently doing nothing — the
|
|
25
|
+
# Python setattr would have raised too (AttributeError on a slotless
|
|
26
|
+
# write is not swallowed anywhere in the engine).
|
|
27
|
+
raise ArgumentError, "unknown stat: #{name}" unless ATTRS.include?(key)
|
|
28
|
+
|
|
29
|
+
@lock.synchronize { instance_variable_set("@#{key}", instance_variable_get("@#{key}") + 1) }
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The engine's `_interrupted` global as an object, so the Runner can be
|
|
35
|
+
# handed a fresh one per run (a Rails worker cancels one job without
|
|
36
|
+
# poisoning the next). trip() is what _signal_handler does; tripped? is the
|
|
37
|
+
# `if _interrupted:` guard sprinkled through the run loop.
|
|
38
|
+
#
|
|
39
|
+
# NOT named `Interrupt`: that is a Ruby core class (the SIGINT exception), and
|
|
40
|
+
# this module is meant to be `include`d by a Rails host — the collision would
|
|
41
|
+
# shadow the core class for that host.
|
|
42
|
+
#
|
|
43
|
+
# Deliberately lock-free: trip() is called from a signal-trap context, where
|
|
44
|
+
# acquiring a Mutex raises ThreadError on modern Ruby. A single boolean
|
|
45
|
+
# write/read is safe under MRI's GVL, which is exactly what the Python global
|
|
46
|
+
# relied on too.
|
|
47
|
+
class InterruptFlag
|
|
48
|
+
def initialize
|
|
49
|
+
@flag = false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def trip
|
|
53
|
+
@flag = true
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def tripped?
|
|
58
|
+
@flag
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zer0ImageGenerator
|
|
4
|
+
module Support
|
|
5
|
+
# A bit-exact reimplementation of CPython's `random.Random`.
|
|
6
|
+
#
|
|
7
|
+
# Why this exists: the SVG generators ported from `tools/svg/` derive every
|
|
8
|
+
# coordinate from a seeded stream, and the sample artwork committed under
|
|
9
|
+
# `docs/samples/` was produced by CPython's Mersenne Twister. Ruby ships
|
|
10
|
+
# MT19937 too, but seeds it differently — `Random.new(7)` and
|
|
11
|
+
# `random.Random(7)` diverge on the first draw — so a port built on Ruby's
|
|
12
|
+
# own RNG would silently re-skin every existing piece.
|
|
13
|
+
#
|
|
14
|
+
# Reimplementing CPython's seeding (`init_by_array`) and its derived
|
|
15
|
+
# methods (`random`, `getrandbits`, `_randbelow`, `randrange`, `choice`,
|
|
16
|
+
# `uniform`) keeps the ported generators byte-identical to their Python
|
|
17
|
+
# originals, which `test/test_py_random.rb` and the golden-SVG tests pin.
|
|
18
|
+
#
|
|
19
|
+
# Only the surface the generators actually use is implemented; anything
|
|
20
|
+
# else would be untested weight.
|
|
21
|
+
class PyRandom
|
|
22
|
+
N = 624
|
|
23
|
+
M = 397
|
|
24
|
+
MATRIX_A = 0x9908b0df
|
|
25
|
+
UPPER_MASK = 0x80000000
|
|
26
|
+
LOWER_MASK = 0x7fffffff
|
|
27
|
+
MASK32 = 0xffffffff
|
|
28
|
+
|
|
29
|
+
# CPython divides `random()`'s 53 bits as 26 high + 27 low.
|
|
30
|
+
TWO_POW_26 = 67_108_864.0
|
|
31
|
+
TWO_POW_53 = 9_007_199_254_740_992.0
|
|
32
|
+
|
|
33
|
+
def initialize(seed = 0)
|
|
34
|
+
@mt = Array.new(N, 0)
|
|
35
|
+
@mti = N + 1
|
|
36
|
+
seed!(seed)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# CPython's random_seed(): integers are seeded by absolute value, split
|
|
40
|
+
# little-endian into 32-bit words, through init_by_array.
|
|
41
|
+
def seed!(seed)
|
|
42
|
+
n = seed.to_i.abs
|
|
43
|
+
key = []
|
|
44
|
+
if n.zero?
|
|
45
|
+
key << 0
|
|
46
|
+
else
|
|
47
|
+
while n.positive?
|
|
48
|
+
key << (n & MASK32)
|
|
49
|
+
n >>= 32
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
init_by_array(key)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def genrand_uint32
|
|
56
|
+
if @mti >= N
|
|
57
|
+
(0...(N - M)).each do |kk|
|
|
58
|
+
y = (@mt[kk] & UPPER_MASK) | (@mt[kk + 1] & LOWER_MASK)
|
|
59
|
+
@mt[kk] = @mt[kk + M] ^ (y >> 1) ^ (y.odd? ? MATRIX_A : 0)
|
|
60
|
+
end
|
|
61
|
+
((N - M)...(N - 1)).each do |kk|
|
|
62
|
+
y = (@mt[kk] & UPPER_MASK) | (@mt[kk + 1] & LOWER_MASK)
|
|
63
|
+
@mt[kk] = @mt[kk + (M - N)] ^ (y >> 1) ^ (y.odd? ? MATRIX_A : 0)
|
|
64
|
+
end
|
|
65
|
+
y = (@mt[N - 1] & UPPER_MASK) | (@mt[0] & LOWER_MASK)
|
|
66
|
+
@mt[N - 1] = @mt[M - 1] ^ (y >> 1) ^ (y.odd? ? MATRIX_A : 0)
|
|
67
|
+
@mti = 0
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
y = @mt[@mti]
|
|
71
|
+
@mti += 1
|
|
72
|
+
y ^= (y >> 11)
|
|
73
|
+
y ^= (y << 7) & 0x9d2c5680
|
|
74
|
+
y ^= (y << 15) & 0xefc60000
|
|
75
|
+
y &= MASK32
|
|
76
|
+
y ^ (y >> 18)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# CPython: (a >> 5) * 2**26 + (b >> 6), scaled into [0, 1).
|
|
80
|
+
def random
|
|
81
|
+
a = genrand_uint32 >> 5
|
|
82
|
+
b = genrand_uint32 >> 6
|
|
83
|
+
((a * TWO_POW_26) + b) / TWO_POW_53
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def getrandbits(k)
|
|
87
|
+
raise ArgumentError, "number of bits must be non-negative" if k.negative?
|
|
88
|
+
return 0 if k.zero?
|
|
89
|
+
return genrand_uint32 >> (32 - k) if k <= 32
|
|
90
|
+
|
|
91
|
+
words = ((k - 1) / 32) + 1
|
|
92
|
+
result = 0
|
|
93
|
+
remaining = k
|
|
94
|
+
words.times do |i|
|
|
95
|
+
r = genrand_uint32
|
|
96
|
+
r >>= (32 - remaining) if remaining < 32
|
|
97
|
+
result |= r << (32 * i)
|
|
98
|
+
remaining -= 32
|
|
99
|
+
end
|
|
100
|
+
result
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# CPython's _randbelow_with_getrandbits: rejection-sample bit_length bits.
|
|
104
|
+
def randbelow(n)
|
|
105
|
+
return 0 unless n.positive?
|
|
106
|
+
|
|
107
|
+
k = n.bit_length
|
|
108
|
+
r = getrandbits(k)
|
|
109
|
+
r = getrandbits(k) while r >= n
|
|
110
|
+
r
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def randrange(start, stop = nil, step = 1)
|
|
114
|
+
if stop.nil?
|
|
115
|
+
raise ArgumentError, "empty range for randrange(#{start})" unless start.positive?
|
|
116
|
+
|
|
117
|
+
return randbelow(start)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
width = stop - start
|
|
121
|
+
if step == 1
|
|
122
|
+
raise ArgumentError, "empty range for randrange(#{start}, #{stop})" unless width.positive?
|
|
123
|
+
|
|
124
|
+
return start + randbelow(width)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
n = step.positive? ? ((width + step - 1) / step) : ((width + step + 1) / step)
|
|
128
|
+
raise ArgumentError, "empty range for randrange(#{start}, #{stop}, #{step})" unless n.positive?
|
|
129
|
+
|
|
130
|
+
start + (step * randbelow(n))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def randint(a, b)
|
|
134
|
+
randrange(a, b + 1)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def choice(seq)
|
|
138
|
+
raise ArgumentError, "cannot choose from an empty sequence" if seq.empty?
|
|
139
|
+
|
|
140
|
+
seq[randbelow(seq.length)]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def uniform(a, b)
|
|
144
|
+
a + ((b - a) * random)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
def init_genrand(s)
|
|
150
|
+
@mt[0] = s & MASK32
|
|
151
|
+
(1...N).each do |i|
|
|
152
|
+
@mt[i] = (1_812_433_253 * (@mt[i - 1] ^ (@mt[i - 1] >> 30)) + i) & MASK32
|
|
153
|
+
end
|
|
154
|
+
@mti = N
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def init_by_array(key)
|
|
158
|
+
init_genrand(19_650_218)
|
|
159
|
+
i = 1
|
|
160
|
+
j = 0
|
|
161
|
+
k = [N, key.length].max
|
|
162
|
+
while k.positive?
|
|
163
|
+
@mt[i] = ((@mt[i] ^ ((@mt[i - 1] ^ (@mt[i - 1] >> 30)) * 1_664_525)) + key[j] + j) & MASK32
|
|
164
|
+
i += 1
|
|
165
|
+
j += 1
|
|
166
|
+
if i >= N
|
|
167
|
+
@mt[0] = @mt[N - 1]
|
|
168
|
+
i = 1
|
|
169
|
+
end
|
|
170
|
+
j = 0 if j >= key.length
|
|
171
|
+
k -= 1
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
k = N - 1
|
|
175
|
+
while k.positive?
|
|
176
|
+
@mt[i] = ((@mt[i] ^ ((@mt[i - 1] ^ (@mt[i - 1] >> 30)) * 1_566_083_941)) - i) & MASK32
|
|
177
|
+
i += 1
|
|
178
|
+
if i >= N
|
|
179
|
+
@mt[0] = @mt[N - 1]
|
|
180
|
+
i = 1
|
|
181
|
+
end
|
|
182
|
+
k -= 1
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
@mt[0] = UPPER_MASK
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|