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,573 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
require_relative "logging"
|
|
7
|
+
require_relative "stats"
|
|
8
|
+
|
|
9
|
+
module Zer0ImageGenerator
|
|
10
|
+
# The single seam between this slice (Runner/CLI) and the settings/content/
|
|
11
|
+
# providers/claude slices. Every cross-slice symbol the orchestrator needs is
|
|
12
|
+
# resolved here so that (a) a Rails host or a test can inject fakes and (b)
|
|
13
|
+
# integration wiring touches exactly one class. The Python engine reached
|
|
14
|
+
# these as module-level functions and the PROVIDERS dict; the default
|
|
15
|
+
# implementation below forwards to their Ruby ports. Method bodies resolve
|
|
16
|
+
# the constants lazily (at call time, not load time) so this file loads even
|
|
17
|
+
# before the sibling slices are present.
|
|
18
|
+
#
|
|
19
|
+
# NOTE: the exact Ruby names of the ported symbols are owned by the other
|
|
20
|
+
# slices — see INTEGRATION_NEEDS. Adjust ONLY the forwarding in this class if
|
|
21
|
+
# they differ; nothing above it should change.
|
|
22
|
+
class Collaborators
|
|
23
|
+
# -- content / front matter ------------------------------------------
|
|
24
|
+
def read_authors(project_root, authors_file)
|
|
25
|
+
Zer0ImageGenerator.read_authors(project_root, authors_file)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Content-file helpers live under Content; settings/config helpers are
|
|
29
|
+
# module-level (module_function). Route each delegate to its real home so
|
|
30
|
+
# the orchestrator sees one flat collaborator surface.
|
|
31
|
+
def parse_front_matter(path, fm_key)
|
|
32
|
+
Content.parse_front_matter(path, fm_key)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def check_preview_exists(preview, settings, root)
|
|
36
|
+
Content.check_preview_exists(preview, settings, root)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def find_preview_image(preview, settings, root)
|
|
40
|
+
Content.find_preview_image(preview, settings, root)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def generate_filename(title)
|
|
44
|
+
Content.generate_filename(title)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def file_preview_settings(settings, cf, authors)
|
|
48
|
+
Zer0ImageGenerator.file_preview_settings(settings, cf, authors)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def build_prompt(cf, settings)
|
|
52
|
+
Prompt.build_prompt(cf, settings)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def build_enhance_prompt(cf, settings)
|
|
56
|
+
Prompt.build_enhance_prompt(cf, settings)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def preview_front_matter_path(settings, filename)
|
|
60
|
+
Content.preview_front_matter_path(settings, filename)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def update_front_matter(path, value, dry_run:, key:)
|
|
64
|
+
Content.update_front_matter(path, value, dry_run: dry_run, key: key)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# -- claude orchestration --------------------------------------------
|
|
68
|
+
def claude_article_brief(client, cf, settings, base_prompt)
|
|
69
|
+
Orchestration.claude_article_brief(client, cf, settings, base_prompt)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def claude_review_image(client, image_path, cf, prompt, settings)
|
|
73
|
+
Orchestration.claude_review_image(client, image_path, cf, prompt, settings)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# -- providers -------------------------------------------------------
|
|
77
|
+
def providers
|
|
78
|
+
Zer0ImageGenerator::PROVIDERS
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def provider_names_sorted
|
|
82
|
+
providers.keys.sort
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The class raised by a provider that cannot enhance, caught in the
|
|
86
|
+
# --enhance fallback to OpenAI.
|
|
87
|
+
def edit_unsupported
|
|
88
|
+
Zer0ImageGenerator::EditUnsupported
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# -- run context -----------------------------------------------------
|
|
92
|
+
# Per-file copy of the RunContext with a fresh slug. This copy is the
|
|
93
|
+
# thread-safety linchpin: the local provider derives its deterministic
|
|
94
|
+
# seed from ctx.slug, so workers must never share one mutable slug.
|
|
95
|
+
def with_slug(ctx, slug, article: nil)
|
|
96
|
+
ctx.with_slug(slug, article: article)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The page context the local renderer derives its art from. Resolved
|
|
100
|
+
# through this seam like every other cross-slice symbol, so a Rails host or
|
|
101
|
+
# a test can inject its own without loading the settings slice.
|
|
102
|
+
def article_art_context(content_file, slug)
|
|
103
|
+
Zer0ImageGenerator.article_art_context(content_file, slug)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def new_run_context(project_root, env)
|
|
107
|
+
Zer0ImageGenerator::RunContext.new(project_root: project_root, env: env)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# -- constants -------------------------------------------------------
|
|
111
|
+
# Serial-mode pacing between paid API calls. The RUNTIME value is the
|
|
112
|
+
# rebindable module accessor (the test suite sets it to 0); the frozen
|
|
113
|
+
# POST_GENERATION_SLEEP constant is only the published default.
|
|
114
|
+
def post_generation_sleep
|
|
115
|
+
Zer0ImageGenerator.post_generation_sleep
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# -- CLI wiring (main) -----------------------------------------------
|
|
119
|
+
def ensure_yaml
|
|
120
|
+
# Ruby's YAML (Psych) is always in stdlib, so the Python PyYAML guard is
|
|
121
|
+
# a no-op here. Kept as a seam so main() reads the same as the oracle.
|
|
122
|
+
Zer0ImageGenerator.ensure_yaml if Zer0ImageGenerator.respond_to?(:ensure_yaml)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def load_dotenv
|
|
126
|
+
# config.rb keeps the Python name, underscore and all.
|
|
127
|
+
Zer0ImageGenerator._load_dotenv
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def find_project_root
|
|
131
|
+
Zer0ImageGenerator.find_project_root
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def read_config(project_root)
|
|
135
|
+
Zer0ImageGenerator.read_config(project_root)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def apply_source_root(project_root, config)
|
|
139
|
+
Zer0ImageGenerator.apply_source_root(project_root, config)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def resolve_settings(args, config)
|
|
143
|
+
Zer0ImageGenerator.resolve_settings(args, config)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Graceful Claude degrade: a copy with prompt_engine/review_engine turned
|
|
147
|
+
# off (Python's `replace(settings, prompt_engine="template", review_engine="none")`).
|
|
148
|
+
# Settings exposes the dataclasses.replace port as dup_with.
|
|
149
|
+
def degrade_settings(settings)
|
|
150
|
+
settings.dup_with(prompt_engine: "template", review_engine: "none")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def default_model(provider_name)
|
|
154
|
+
providers[provider_name].default_model
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def claude_credential_hint
|
|
158
|
+
Zer0ImageGenerator::CLAUDE_CREDENTIAL_HINT
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
class Runner
|
|
163
|
+
attr_reader :settings, :root, :stats, :authors, :ctx
|
|
164
|
+
|
|
165
|
+
def initialize(settings, project_root, ctx: nil, deps: nil, logger: nil, interrupt: nil)
|
|
166
|
+
@settings = settings
|
|
167
|
+
@root = Pathname.new(project_root.to_s)
|
|
168
|
+
@deps = deps || Collaborators.new
|
|
169
|
+
@logger = logger || Logging.logger
|
|
170
|
+
@interrupt = interrupt || InterruptFlag.new
|
|
171
|
+
@stats = Stats.new
|
|
172
|
+
@authors = @deps.read_authors(@root, settings.authors_file)
|
|
173
|
+
@ctx = ctx || @deps.new_run_context(@root, ENV.to_h)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# -- discovery -------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
# `<root>/<collections_dir>/_<name>` — collections_dir is Jekyll's own key
|
|
179
|
+
# (default "" = site root; zer0-mistakes sets `pages`).
|
|
180
|
+
def collection_path(name)
|
|
181
|
+
base = @root
|
|
182
|
+
@settings.collections_dir.split("/").each do |part|
|
|
183
|
+
base = base / part unless part.empty?
|
|
184
|
+
end
|
|
185
|
+
base / "_#{name}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def discover(collection_path)
|
|
189
|
+
unless collection_path.directory?
|
|
190
|
+
warn("Collection directory not found: #{collection_path}")
|
|
191
|
+
return []
|
|
192
|
+
end
|
|
193
|
+
# Python: sorted(collection_path.rglob("*.md")). rglob descends into and
|
|
194
|
+
# matches dotfiles/dot-dirs (FNM_DOTMATCH) but does NOT follow symlinked
|
|
195
|
+
# dirs — Ruby's `**` matches that default. The sort must be BY PATH
|
|
196
|
+
# COMPONENTS, not by the full string: pathlib compares the parts tuple,
|
|
197
|
+
# where a dir name that is a prefix of a sibling file sorts first
|
|
198
|
+
# ('a'/'c.md' < 'a b.md' < 'a.md'), which a plain String#sort gets wrong
|
|
199
|
+
# because '/' (0x2F) outranks ' ' and '.'.
|
|
200
|
+
base = collection_path.to_s
|
|
201
|
+
Dir.glob("**/*.md", File::FNM_DOTMATCH, base: base)
|
|
202
|
+
.sort_by { |rel| rel.split("/") }
|
|
203
|
+
.map { |rel| collection_path / rel }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# -- per-file processing --------------------------------------------
|
|
207
|
+
|
|
208
|
+
def process_file(path)
|
|
209
|
+
return if @interrupt.tripped?
|
|
210
|
+
|
|
211
|
+
settings = @settings
|
|
212
|
+
@stats.inc(:processed)
|
|
213
|
+
debug("Processing file: #{path}")
|
|
214
|
+
|
|
215
|
+
cf = @deps.parse_front_matter(path, settings.front_matter_key)
|
|
216
|
+
if cf.nil?
|
|
217
|
+
@stats.inc(:skipped)
|
|
218
|
+
return
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
if settings.enhance
|
|
222
|
+
enhance_file(cf)
|
|
223
|
+
return
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Generate mode ---------------------------------------------------
|
|
227
|
+
if cf.preview && @deps.check_preview_exists(cf.preview, settings, @root)
|
|
228
|
+
unless settings.force
|
|
229
|
+
debug("Preview already exists and is valid: #{cf.preview}")
|
|
230
|
+
@stats.inc(:skipped)
|
|
231
|
+
return
|
|
232
|
+
end
|
|
233
|
+
info("Force mode: regenerating preview for #{cf.title}")
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
if settings.list_only
|
|
237
|
+
say("#{Colors::YELLOW}Missing preview:#{Colors::NC} #{path}")
|
|
238
|
+
say(" Title: #{cf.title}")
|
|
239
|
+
say(" Current preview (not found): #{cf.preview}") if cf.preview
|
|
240
|
+
say
|
|
241
|
+
return
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
info("Generating preview for: #{cf.title}")
|
|
245
|
+
|
|
246
|
+
slug = @deps.generate_filename(cf.title)
|
|
247
|
+
if slug.nil? || slug.empty?
|
|
248
|
+
warn("Cannot derive filename from title in #{path}")
|
|
249
|
+
@stats.inc(:errors)
|
|
250
|
+
return
|
|
251
|
+
end
|
|
252
|
+
out_base = @root / settings.output_dir / slug
|
|
253
|
+
|
|
254
|
+
# Style overrides apply only once an image/prompt is actually built.
|
|
255
|
+
file_settings = @deps.file_preview_settings(settings, cf, @authors)
|
|
256
|
+
|
|
257
|
+
# ---- Analyze: Claude reads the article and writes the art brief ----
|
|
258
|
+
base_prompt = @deps.build_prompt(cf, file_settings)
|
|
259
|
+
prompt = base_prompt
|
|
260
|
+
orchestrated = settings.provider != "local" # local is deterministic
|
|
261
|
+
if orchestrated && file_settings.prompt_engine == "claude" && !settings.dry_run
|
|
262
|
+
prompt = @deps.claude_article_brief(@ctx.claude, cf, file_settings, base_prompt)
|
|
263
|
+
end
|
|
264
|
+
debug("Generated prompt: #{prompt[0, 500]}...")
|
|
265
|
+
|
|
266
|
+
if settings.dry_run
|
|
267
|
+
info("[DRY RUN] Would generate image:")
|
|
268
|
+
say(" Provider: #{settings.provider}")
|
|
269
|
+
if orchestrated && file_settings.prompt_engine == "claude"
|
|
270
|
+
say(" Prompt engine: claude (article analysis runs at generation time)")
|
|
271
|
+
end
|
|
272
|
+
if orchestrated && file_settings.review_engine == "claude"
|
|
273
|
+
say(" Review: claude (image review runs at generation time)")
|
|
274
|
+
end
|
|
275
|
+
say(" Output: #{out_base.sub_ext('.png')}")
|
|
276
|
+
say(" Preview path: #{@deps.preview_front_matter_path(file_settings, slug + '.png')}")
|
|
277
|
+
say(" Prompt: #{prompt[0, 400]}...")
|
|
278
|
+
say
|
|
279
|
+
@stats.inc(:generated)
|
|
280
|
+
return
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
provider = @deps.providers[settings.provider]
|
|
284
|
+
# Per-file context copy: workers must not share a mutable slug (the local
|
|
285
|
+
# provider derives its deterministic seed from it). The copy carries the
|
|
286
|
+
# shared AnthropicClient reference, which is stateless after init and
|
|
287
|
+
# therefore thread-safe.
|
|
288
|
+
file_ctx = @deps.with_slug(@ctx, slug,
|
|
289
|
+
article: @deps.article_art_context(cf, slug))
|
|
290
|
+
# ---- Produce: the selected raster model renders the brief ----
|
|
291
|
+
result = provider.generate(prompt, file_settings, out_base, file_ctx)
|
|
292
|
+
|
|
293
|
+
# ---- Review: Claude inspects the render; at most ONE regeneration ----
|
|
294
|
+
if orchestrated && file_settings.review_engine == "claude" &&
|
|
295
|
+
result.ok && result.path && result.kind == "png"
|
|
296
|
+
approved, critique, revised =
|
|
297
|
+
@deps.claude_review_image(@ctx.claude, result.path, cf, prompt, file_settings)
|
|
298
|
+
if approved
|
|
299
|
+
debug("Claude review: #{critique}") if critique && !critique.empty?
|
|
300
|
+
else
|
|
301
|
+
info(" ↳ Claude review requested a revision: #{critique}")
|
|
302
|
+
debug("Revised prompt: #{revised[0, 300]}...")
|
|
303
|
+
retry_result = provider.generate(revised, file_settings, out_base, file_ctx)
|
|
304
|
+
if retry_result.ok && retry_result.path
|
|
305
|
+
result = retry_result
|
|
306
|
+
success(" ↳ Regenerated with Claude's revised brief")
|
|
307
|
+
else
|
|
308
|
+
warn(" ↳ Revision render failed " \
|
|
309
|
+
"(#{retry_result.error || 'unknown'}); keeping the first image")
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
if result.ok && result.path
|
|
315
|
+
fm_value = @deps.preview_front_matter_path(file_settings, result.path.basename.to_s)
|
|
316
|
+
if @deps.update_front_matter(path, fm_value, dry_run: false, key: settings.front_matter_key)
|
|
317
|
+
@stats.inc(:generated)
|
|
318
|
+
# Serial-mode pacing between paid API calls (historical behavior). In
|
|
319
|
+
# parallel mode a per-worker sleep throttles nothing — it only burns
|
|
320
|
+
# worker capacity — so skip it.
|
|
321
|
+
sleep_secs = @deps.post_generation_sleep
|
|
322
|
+
sleep(sleep_secs) if sleep_secs && !sleep_secs.zero? && settings.parallel <= 1
|
|
323
|
+
else
|
|
324
|
+
@stats.inc(:errors)
|
|
325
|
+
end
|
|
326
|
+
else
|
|
327
|
+
warn("Failed to generate image for: #{cf.title}")
|
|
328
|
+
warn(" #{result.error}") if result.error
|
|
329
|
+
@stats.inc(:errors)
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def enhance_file(cf)
|
|
334
|
+
settings = @settings
|
|
335
|
+
existing = @deps.find_preview_image(cf.preview, settings, @root)
|
|
336
|
+
if existing.nil?
|
|
337
|
+
warn("No existing preview image found for: #{cf.title}")
|
|
338
|
+
warn(" Expected at: #{cf.preview}")
|
|
339
|
+
warn(" Use without --enhance to generate a new image first.")
|
|
340
|
+
@stats.inc(:skipped)
|
|
341
|
+
return
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
info("Enhancing preview for: #{cf.title}")
|
|
345
|
+
file_settings = @deps.file_preview_settings(settings, cf, @authors)
|
|
346
|
+
prompt = @deps.build_enhance_prompt(cf, file_settings)
|
|
347
|
+
debug("Enhancement prompt: #{prompt[0, 400]}...")
|
|
348
|
+
|
|
349
|
+
if settings.dry_run
|
|
350
|
+
info("[DRY RUN] Would enhance image:")
|
|
351
|
+
say(" Source: #{existing}")
|
|
352
|
+
say(" Model: #{settings.enhance_model}")
|
|
353
|
+
say(" Prompt: #{prompt[0, 400]}...")
|
|
354
|
+
say
|
|
355
|
+
@stats.inc(:enhanced)
|
|
356
|
+
return
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# --enhance-format other than the current extension writes a NEW file
|
|
360
|
+
# (content and extension must agree) and repoints the front matter; the
|
|
361
|
+
# default png-onto-png flow enhances in place with a backup.
|
|
362
|
+
out_path = existing.sub_ext("." + settings.enhance_format)
|
|
363
|
+
in_place = out_path == existing
|
|
364
|
+
|
|
365
|
+
if in_place
|
|
366
|
+
backup = existing.dirname / "#{existing.basename(existing.extname)}_pre-enhance#{existing.extname}"
|
|
367
|
+
if !backup.exist?
|
|
368
|
+
FileUtils.cp(existing.to_s, backup.to_s, preserve: true)
|
|
369
|
+
info("Original backed up to: #{backup.basename}")
|
|
370
|
+
else
|
|
371
|
+
debug("Backup already exists: #{backup}")
|
|
372
|
+
end
|
|
373
|
+
else
|
|
374
|
+
backup = existing # original file is untouched and acts as the fallback
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Capability-driven routing: the active provider's edit() runs when it
|
|
378
|
+
# has one; EditUnsupported falls back to OpenAI (historical behavior).
|
|
379
|
+
provider = @deps.providers[settings.provider]
|
|
380
|
+
begin
|
|
381
|
+
result = provider.edit(existing, prompt, file_settings, @ctx, out_path)
|
|
382
|
+
rescue @deps.edit_unsupported
|
|
383
|
+
warn("Enhancement not supported for provider: #{settings.provider} " \
|
|
384
|
+
"(falling back to OpenAI)")
|
|
385
|
+
openai_provider = @deps.providers["openai"]
|
|
386
|
+
unless openai_provider.is_configured(@ctx.env)
|
|
387
|
+
warn("Enhance requires OPENAI_API_KEY (the images/edits API is OpenAI-only).")
|
|
388
|
+
@stats.inc(:errors)
|
|
389
|
+
return
|
|
390
|
+
end
|
|
391
|
+
result = openai_provider.edit(existing, prompt, file_settings, @ctx, out_path)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
if result.ok
|
|
395
|
+
if !in_place && result.path && result.path != existing
|
|
396
|
+
old_value = cf.preview || ""
|
|
397
|
+
new_value =
|
|
398
|
+
if old_value.include?("/")
|
|
399
|
+
old_value.rpartition("/")[0] + "/" + result.path.basename.to_s
|
|
400
|
+
else
|
|
401
|
+
result.path.basename.to_s
|
|
402
|
+
end
|
|
403
|
+
@deps.update_front_matter(cf.path, new_value, dry_run: false,
|
|
404
|
+
key: settings.front_matter_key)
|
|
405
|
+
info("Preview extension changed: #{existing.basename} → #{result.path.basename}")
|
|
406
|
+
end
|
|
407
|
+
success("Enhanced image saved to: #{result.path || existing}")
|
|
408
|
+
@stats.inc(:enhanced)
|
|
409
|
+
else
|
|
410
|
+
warn("Failed to enhance image for: #{cf.title}")
|
|
411
|
+
warn(" #{result.error}") if result.error
|
|
412
|
+
info("Original preserved at: #{backup}")
|
|
413
|
+
@stats.inc(:errors)
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# -- collection / run loop ------------------------------------------
|
|
418
|
+
|
|
419
|
+
def process_collection(collection_path)
|
|
420
|
+
files = discover(collection_path)
|
|
421
|
+
files = files[0, @settings.batch] if @settings.batch.positive?
|
|
422
|
+
return if files.nil? || files.empty?
|
|
423
|
+
|
|
424
|
+
serial = @settings.list_only || @settings.dry_run || @settings.parallel <= 1
|
|
425
|
+
if serial
|
|
426
|
+
files.each do |file_path|
|
|
427
|
+
if @interrupt.tripped?
|
|
428
|
+
warn("Interrupted! Stopping...")
|
|
429
|
+
break
|
|
430
|
+
end
|
|
431
|
+
process_file(file_path)
|
|
432
|
+
end
|
|
433
|
+
return
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
run_parallel(files)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def run
|
|
440
|
+
settings = @settings
|
|
441
|
+
if settings.file && !settings.file.empty?
|
|
442
|
+
target = Pathname.new(settings.file)
|
|
443
|
+
unless target.absolute?
|
|
444
|
+
# Source-relative first; then cwd-relative, so repo-root paths like
|
|
445
|
+
# pages/_notes/x.md still work on `source:` sites.
|
|
446
|
+
target = @root / settings.file
|
|
447
|
+
if !target.file? && (Pathname.pwd / settings.file).file?
|
|
448
|
+
target = Pathname.pwd / settings.file
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
error_exit("File not found: #{settings.file}") unless target.file?
|
|
452
|
+
process_file(target)
|
|
453
|
+
elsif settings.collection && !settings.collection.empty? && settings.collection != "all"
|
|
454
|
+
path = collection_path(settings.collection)
|
|
455
|
+
unless path.directory?
|
|
456
|
+
available = settings.collections.join(", ")
|
|
457
|
+
error_exit("Unknown collection: #{settings.collection}. " \
|
|
458
|
+
"Available: #{available}, all")
|
|
459
|
+
end
|
|
460
|
+
step("Processing #{settings.collection} collection...")
|
|
461
|
+
process_collection(path)
|
|
462
|
+
else
|
|
463
|
+
step("Processing all configured collections...")
|
|
464
|
+
settings.collections.each do |name|
|
|
465
|
+
path = collection_path(name)
|
|
466
|
+
if path.directory?
|
|
467
|
+
step("Processing #{name} collection...")
|
|
468
|
+
process_collection(path)
|
|
469
|
+
else
|
|
470
|
+
warn("Collection directory not found: #{path}")
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
say
|
|
476
|
+
print_header("📊 Summary")
|
|
477
|
+
say(" Files processed: #{@stats.processed}")
|
|
478
|
+
say(" Images generated: #{@stats.generated}")
|
|
479
|
+
say(" Images enhanced: #{@stats.enhanced}")
|
|
480
|
+
say(" Files skipped: #{@stats.skipped}")
|
|
481
|
+
say(" Errors: #{@stats.errors}")
|
|
482
|
+
say
|
|
483
|
+
|
|
484
|
+
info("This was a dry run. No actual changes were made.") if settings.dry_run
|
|
485
|
+
if @stats.errors.positive?
|
|
486
|
+
warn("Some files had errors. Check the output above.")
|
|
487
|
+
return 1
|
|
488
|
+
end
|
|
489
|
+
success("Preview image generation complete!")
|
|
490
|
+
0
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
private
|
|
494
|
+
|
|
495
|
+
# Fixed pool of `parallel` worker threads draining a shared queue. This
|
|
496
|
+
# mirrors the Python ThreadPoolExecutor(max_workers=parallel): at most N
|
|
497
|
+
# concurrent process_file calls, each file handled once. A tripped
|
|
498
|
+
# interrupt stops the pool from claiming NEW work (in-flight files finish),
|
|
499
|
+
# matching process_file's own `if _interrupted: return` guard plus the
|
|
500
|
+
# as_completed cancel/break. A worker exception is caught here (as the
|
|
501
|
+
# Python surfaced it via future.exception()) → warn + errors++.
|
|
502
|
+
def run_parallel(files)
|
|
503
|
+
queue = Queue.new
|
|
504
|
+
files.each { |f| queue << f }
|
|
505
|
+
workers = [@settings.parallel, files.length].min
|
|
506
|
+
threads = Array.new(workers) do
|
|
507
|
+
Thread.new do
|
|
508
|
+
loop do
|
|
509
|
+
file_path = begin
|
|
510
|
+
queue.pop(true)
|
|
511
|
+
rescue ThreadError
|
|
512
|
+
break # queue drained
|
|
513
|
+
end
|
|
514
|
+
break if @interrupt.tripped?
|
|
515
|
+
|
|
516
|
+
begin
|
|
517
|
+
process_file(file_path)
|
|
518
|
+
rescue StandardError => e
|
|
519
|
+
warn("Worker failed on #{file_path}: #{e}")
|
|
520
|
+
@stats.inc(:errors)
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
# First interrupt observed during the join drains the queue so no idle
|
|
526
|
+
# worker picks up more — the Python cancels pending futures for the same
|
|
527
|
+
# effect.
|
|
528
|
+
announced = false
|
|
529
|
+
until threads.all? { |t| !t.alive? }
|
|
530
|
+
if @interrupt.tripped? && !announced
|
|
531
|
+
queue.clear
|
|
532
|
+
warn("Interrupted! Cancelling remaining tasks...")
|
|
533
|
+
announced = true
|
|
534
|
+
end
|
|
535
|
+
threads.each { |t| t.join(0.05) }
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# -- log delegation (injectable sink) --------------------------------
|
|
540
|
+
|
|
541
|
+
def info(message)
|
|
542
|
+
@logger.info(message)
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def step(message)
|
|
546
|
+
@logger.step(message)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def success(message)
|
|
550
|
+
@logger.success(message)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def warn(message)
|
|
554
|
+
@logger.warn(message)
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def debug(message)
|
|
558
|
+
@logger.debug(message)
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def say(text = "")
|
|
562
|
+
@logger.say(text)
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def print_header(title)
|
|
566
|
+
@logger.print_header(title)
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def error_exit(message, status = 1)
|
|
570
|
+
@logger.error_exit(message, status)
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
end
|