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