ruby_spriter 0.6.5

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 (40) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/CHANGELOG.md +217 -0
  4. data/Gemfile +17 -0
  5. data/LICENSE +21 -0
  6. data/README.md +561 -0
  7. data/bin/ruby_spriter +20 -0
  8. data/lib/ruby_spriter/cli.rb +249 -0
  9. data/lib/ruby_spriter/consolidator.rb +146 -0
  10. data/lib/ruby_spriter/dependency_checker.rb +174 -0
  11. data/lib/ruby_spriter/gimp_processor.rb +664 -0
  12. data/lib/ruby_spriter/metadata_manager.rb +116 -0
  13. data/lib/ruby_spriter/platform.rb +82 -0
  14. data/lib/ruby_spriter/processor.rb +251 -0
  15. data/lib/ruby_spriter/utils/file_helper.rb +57 -0
  16. data/lib/ruby_spriter/utils/output_formatter.rb +65 -0
  17. data/lib/ruby_spriter/utils/path_helper.rb +59 -0
  18. data/lib/ruby_spriter/version.rb +7 -0
  19. data/lib/ruby_spriter/video_processor.rb +139 -0
  20. data/lib/ruby_spriter.rb +31 -0
  21. data/ruby_spriter.gemspec +42 -0
  22. data/spec/fixtures/image_without_metadata.png +0 -0
  23. data/spec/fixtures/spritesheet_4x2.png +0 -0
  24. data/spec/fixtures/spritesheet_4x4.png +0 -0
  25. data/spec/fixtures/spritesheet_6x2.png +0 -0
  26. data/spec/fixtures/spritesheet_with_metadata.png +0 -0
  27. data/spec/fixtures/test_video.mp4 +0 -0
  28. data/spec/ruby_spriter/cli_spec.rb +1142 -0
  29. data/spec/ruby_spriter/consolidator_spec.rb +375 -0
  30. data/spec/ruby_spriter/dependency_checker_spec.rb +0 -0
  31. data/spec/ruby_spriter/gimp_processor_spec.rb +425 -0
  32. data/spec/ruby_spriter/metadata_manager_spec.rb +0 -0
  33. data/spec/ruby_spriter/platform_spec.rb +82 -0
  34. data/spec/ruby_spriter/processor_spec.rb +0 -0
  35. data/spec/ruby_spriter/utils/file_helper_spec.rb +71 -0
  36. data/spec/ruby_spriter/utils/output_formatter_spec.rb +0 -0
  37. data/spec/ruby_spriter/utils/path_helper_spec.rb +78 -0
  38. data/spec/ruby_spriter/video_processor_spec.rb +0 -0
  39. data/spec/spec_helper.rb +41 -0
  40. metadata +88 -0
@@ -0,0 +1,664 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'tmpdir'
5
+
6
+ module RubySpriter
7
+ # Processes images with GIMP
8
+ class GimpProcessor
9
+ attr_reader :options, :gimp_path
10
+
11
+ def initialize(gimp_path, options = {})
12
+ @gimp_path = gimp_path
13
+ @options = options
14
+ end
15
+
16
+ # Process image with GIMP operations
17
+ # @param input_file [String] Path to input image
18
+ # @return [String] Path to processed output file
19
+ def process(input_file)
20
+ Utils::FileHelper.validate_readable!(input_file)
21
+
22
+ Utils::OutputFormatter.header("GIMP Processing")
23
+
24
+ # Inform user if automatic operation order optimization is applied
25
+ if options[:scale_percent] && options[:remove_bg] &&
26
+ options[:operation_order] == :scale_then_remove_bg
27
+ Utils::OutputFormatter.note("Auto-optimized: Removing background before scaling for better quality")
28
+ end
29
+
30
+ working_file = input_file
31
+ operations = determine_operations
32
+
33
+ # Execute operations in configured order
34
+ operations.each do |operation|
35
+ working_file = send(operation, working_file)
36
+ end
37
+
38
+ # Apply sharpening at the very end, after all GIMP operations
39
+ if options[:sharpen]
40
+ working_file = apply_sharpen_imagemagick(working_file)
41
+ end
42
+
43
+ working_file
44
+ end
45
+
46
+ private
47
+
48
+ def determine_operations
49
+ ops = []
50
+
51
+ # Automatically use bg_first when both scaling and background removal are enabled
52
+ # This produces cleaner results because:
53
+ # - Background removal works better at higher resolution
54
+ # - Scaling smooths out any rough edges from background removal
55
+ auto_bg_first = options[:scale_percent] && options[:remove_bg] &&
56
+ options[:operation_order] == :scale_then_remove_bg
57
+
58
+ if options[:operation_order] == :remove_bg_then_scale || auto_bg_first
59
+ ops << :remove_background if options[:remove_bg]
60
+ ops << :scale_image if options[:scale_percent]
61
+ else # :scale_then_remove_bg (when only one operation, or explicitly requested)
62
+ ops << :scale_image if options[:scale_percent]
63
+ ops << :remove_background if options[:remove_bg]
64
+ end
65
+
66
+ ops
67
+ end
68
+
69
+ def scale_image(input_file)
70
+ percent = options[:scale_percent]
71
+ output_file = Utils::FileHelper.output_filename(input_file, "scaled-#{percent}pct")
72
+
73
+ Utils::OutputFormatter.indent("Scaling to #{percent}%...")
74
+
75
+ script = generate_scale_script(input_file, output_file, percent)
76
+ execute_gimp_script(script, output_file, "Scale")
77
+
78
+ # Preserve metadata from input file
79
+ preserve_metadata(input_file, output_file)
80
+
81
+ # Note: Sharpening is applied at the end in process() method
82
+
83
+ output_file
84
+ end
85
+
86
+ def remove_background(input_file)
87
+ method = options[:fuzzy_select] ? 'fuzzy' : 'global'
88
+ output_file = Utils::FileHelper.output_filename(input_file, "nobg-#{method}")
89
+
90
+ Utils::OutputFormatter.indent("Removing background (#{method} select)...")
91
+
92
+ script = generate_remove_bg_script(input_file, output_file)
93
+ execute_gimp_script(script, output_file, "Background Removal")
94
+
95
+ # Preserve metadata from input file
96
+ preserve_metadata(input_file, output_file)
97
+
98
+ output_file
99
+ end
100
+
101
+ def generate_scale_script(input_file, output_file, percent)
102
+ input_path = Utils::PathHelper.normalize_for_python(input_file)
103
+ output_path = Utils::PathHelper.normalize_for_python(output_file)
104
+ interpolation = map_interpolation_method(options[:scale_interpolation] || 'nohalo')
105
+
106
+ # Get sharpen parameters with proper defaults
107
+ sharpen_enabled = options[:sharpen] || false
108
+ sharpen_radius = options[:sharpen_radius] || 3.0
109
+ sharpen_amount = options[:sharpen_amount] || 0.5
110
+ sharpen_threshold = options[:sharpen_threshold] || 0
111
+
112
+ <<~PYTHON
113
+ import sys
114
+ import gc
115
+ from gi.repository import Gimp, Gio, Gegl
116
+
117
+ img = None
118
+ layer = None
119
+
120
+ try:
121
+ print("Loading image...")
122
+ img = Gimp.file_load(Gimp.RunMode.NONINTERACTIVE, Gio.File.new_for_path(r'#{input_path}'))
123
+
124
+ w = img.get_width()
125
+ h = img.get_height()
126
+ print(f"Image size: {w}x{h}")
127
+
128
+ layers = img.get_layers()
129
+ if not layers or len(layers) == 0:
130
+ raise Exception("No layers found")
131
+ layer = layers[0]
132
+
133
+ # Calculate new dimensions
134
+ new_width = int(w * #{percent} / 100.0)
135
+ new_height = int(h * #{percent} / 100.0)
136
+ print(f"Scaling to: {new_width}x{new_height}")
137
+ print(f"Interpolation: #{interpolation}")
138
+
139
+ # Set interpolation method via context
140
+ pdb = Gimp.get_pdb()
141
+ context_set_interp = pdb.lookup_procedure('gimp-context-set-interpolation')
142
+ if context_set_interp:
143
+ config = context_set_interp.create_config()
144
+ config.set_property('interpolation', #{interpolation})
145
+ context_set_interp.run(config)
146
+ print("Interpolation method set in context")
147
+
148
+ # Scale layer using the context interpolation
149
+ scale_proc = pdb.lookup_procedure('gimp-layer-scale')
150
+ if scale_proc:
151
+ config = scale_proc.create_config()
152
+ config.set_property('layer', layer)
153
+ config.set_property('new-width', new_width)
154
+ config.set_property('new-height', new_height)
155
+ config.set_property('local-origin', False)
156
+ scale_proc.run(config)
157
+ print("Layer scaled with interpolation")
158
+
159
+ # Resize canvas to match layer
160
+ img.resize(new_width, new_height, 0, 0)
161
+ print("Canvas resized")
162
+
163
+ # Only flatten if there are multiple layers AND no transparency is needed
164
+ # Otherwise, preserve the alpha channel for transparent images
165
+ layers = img.get_layers()
166
+ if len(layers) > 1:
167
+ # Merge down multiple layers while preserving alpha
168
+ merge_proc = pdb.lookup_procedure('gimp-image-merge-visible-layers')
169
+ if merge_proc:
170
+ config = merge_proc.create_config()
171
+ config.set_property('image', img)
172
+ config.set_property('merge-type', Gimp.MergeType.EXPAND_AS_NECESSARY)
173
+ merge_proc.run(config)
174
+ print("Multiple layers merged (alpha preserved)")
175
+ else:
176
+ print("Single layer - no merge needed, alpha preserved")
177
+
178
+ # Get the final layer
179
+ layers = img.get_layers()
180
+ final_layer = layers[0]
181
+
182
+ # Note: Sharpening will be applied using ImageMagick after GIMP export
183
+ # This is because GEGL operations in GIMP 3.x batch mode are unreliable
184
+
185
+ # Export with alpha channel intact
186
+ print("Exporting with alpha channel...")
187
+ export_proc = pdb.lookup_procedure('file-png-export')
188
+ if export_proc:
189
+ config = export_proc.create_config()
190
+ config.set_property('image', img)
191
+ config.set_property('file', Gio.File.new_for_path(r'#{output_path}'))
192
+ export_proc.run(config)
193
+
194
+ print("SUCCESS - Image scaled!")
195
+
196
+ except Exception as e:
197
+ print(f"ERROR: {e}")
198
+ import traceback
199
+ traceback.print_exc()
200
+ sys.exit(1)
201
+ finally:
202
+ # Explicit cleanup to minimize GEGL warnings
203
+ try:
204
+ if layer is not None:
205
+ layer = None
206
+ if img is not None:
207
+ gc.collect() # Force garbage collection
208
+ img.delete()
209
+ img = None
210
+ gc.collect() # Force again after deletion
211
+ except Exception as cleanup_error:
212
+ print(f"Cleanup warning: {cleanup_error}")
213
+ pass
214
+ PYTHON
215
+ end
216
+
217
+ def generate_remove_bg_script(input_file, output_file)
218
+ input_path = Utils::PathHelper.normalize_for_python(input_file)
219
+ output_path = Utils::PathHelper.normalize_for_python(output_file)
220
+
221
+ use_fuzzy = options[:fuzzy_select]
222
+
223
+ # Build the selection code block
224
+ selection_code = if use_fuzzy
225
+ generate_fuzzy_select_code
226
+ else
227
+ generate_global_select_code
228
+ end
229
+
230
+ # Build optional processing code
231
+ grow_code = generate_grow_selection_code
232
+ feather_code = generate_feather_selection_code
233
+
234
+ <<~PYTHON
235
+ import sys
236
+ import gc
237
+ from gi.repository import Gimp, Gio, Gegl
238
+
239
+ img = None
240
+ layer = None
241
+
242
+ try:
243
+ print("Loading image...")
244
+ img = Gimp.file_load(Gimp.RunMode.NONINTERACTIVE, Gio.File.new_for_path(r'#{input_path}'))
245
+
246
+ w = img.get_width()
247
+ h = img.get_height()
248
+ print(f"Image size: {w}x{h}")
249
+
250
+ layers = img.get_layers()
251
+ if not layers or len(layers) == 0:
252
+ raise Exception("No layers found")
253
+ layer = layers[0]
254
+
255
+ # Add alpha channel if needed
256
+ if not layer.has_alpha():
257
+ layer.add_alpha()
258
+ print("Added alpha channel")
259
+
260
+ pdb = Gimp.get_pdb()
261
+
262
+ # Sample all four corners
263
+ corners = [
264
+ (0, 0), # Top-left
265
+ (w-1, 0), # Top-right
266
+ (0, h-1), # Bottom-left
267
+ (w-1, h-1) # Bottom-right
268
+ ]
269
+
270
+ print(f"Sampling {len(corners)} corners...")
271
+
272
+ #{selection_code.split("\n").map { |line| " " + line }.join("\n")}
273
+
274
+ print("Selection complete")
275
+
276
+ #{grow_code.split("\n").map { |line| " " + line }.join("\n")}
277
+
278
+ #{feather_code.split("\n").map { |line| " " + line }.join("\n")}
279
+
280
+ # Delete selection (clear background)
281
+ print("Removing background...")
282
+ edit_clear = pdb.lookup_procedure('gimp-drawable-edit-clear')
283
+ if edit_clear:
284
+ config = edit_clear.create_config()
285
+ config.set_property('drawable', layer)
286
+ edit_clear.run(config)
287
+ print("Background removed")
288
+
289
+ # Deselect
290
+ print("Deselecting...")
291
+ select_none = pdb.lookup_procedure('gimp-selection-none')
292
+ if select_none:
293
+ config = select_none.create_config()
294
+ config.set_property('image', img)
295
+ select_none.run(config)
296
+
297
+ # Export
298
+ print("Exporting...")
299
+ export_proc = pdb.lookup_procedure('file-png-export')
300
+ if export_proc:
301
+ config = export_proc.create_config()
302
+ config.set_property('image', img)
303
+ config.set_property('file', Gio.File.new_for_path(r'#{output_path}'))
304
+ export_proc.run(config)
305
+
306
+ print("SUCCESS - Background removed!")
307
+
308
+ except Exception as e:
309
+ print(f"ERROR: {e}")
310
+ import traceback
311
+ traceback.print_exc()
312
+ sys.exit(1)
313
+ finally:
314
+ # Explicit cleanup to minimize GEGL warnings
315
+ try:
316
+ if layer is not None:
317
+ layer = None
318
+ if img is not None:
319
+ gc.collect() # Force garbage collection
320
+ img.delete()
321
+ img = None
322
+ gc.collect() # Force again after deletion
323
+ except Exception as cleanup_error:
324
+ print(f"Cleanup warning: {cleanup_error}")
325
+ pass
326
+ PYTHON
327
+ end
328
+
329
+ def generate_fuzzy_select_code
330
+ <<~PYTHON.chomp
331
+ # Fuzzy select (contiguous regions only)
332
+ print("Using FUZZY SELECT (contiguous regions only)")
333
+ select_proc = pdb.lookup_procedure('gimp-image-select-contiguous-color')
334
+
335
+ if not select_proc:
336
+ raise Exception("Could not find gimp-image-select-contiguous-color procedure")
337
+
338
+ for i, (x, y) in enumerate(corners):
339
+ print(f" Corner {i+1} at ({x}, {y})")
340
+
341
+ config = select_proc.create_config()
342
+ config.set_property('image', img)
343
+ config.set_property('operation', Gimp.ChannelOps.REPLACE if i == 0 else Gimp.ChannelOps.ADD)
344
+ config.set_property('drawable', layer)
345
+ config.set_property('x', float(x))
346
+ config.set_property('y', float(y))
347
+ select_proc.run(config)
348
+ PYTHON
349
+ end
350
+
351
+ def generate_global_select_code
352
+ <<~PYTHON.chomp
353
+ # Global color select (all matching pixels)
354
+ print("Using GLOBAL COLOR SELECT (all matching pixels)")
355
+ select_proc = pdb.lookup_procedure('gimp-image-select-color')
356
+
357
+ if not select_proc:
358
+ raise Exception("Could not find gimp-image-select-color procedure")
359
+
360
+ for i, (x, y) in enumerate(corners):
361
+ print(f" Corner {i+1} at ({x}, {y})")
362
+ color = layer.get_pixel(x, y)
363
+
364
+ config = select_proc.create_config()
365
+ config.set_property('image', img)
366
+ config.set_property('operation', Gimp.ChannelOps.REPLACE if i == 0 else Gimp.ChannelOps.ADD)
367
+ config.set_property('drawable', layer)
368
+ config.set_property('color', color)
369
+ select_proc.run(config)
370
+ PYTHON
371
+ end
372
+
373
+ def generate_grow_selection_code
374
+ grow = options[:grow_selection] || 1
375
+ return "# No selection growth" if grow <= 0
376
+
377
+ <<~PYTHON.chomp
378
+ # Grow selection
379
+ print(f"Growing selection by #{grow} pixels...")
380
+ grow_proc = pdb.lookup_procedure('gimp-selection-grow')
381
+ if grow_proc:
382
+ config = grow_proc.create_config()
383
+ config.set_property('image', img)
384
+ config.set_property('steps', #{grow})
385
+ grow_proc.run(config)
386
+ print("Selection grown")
387
+ PYTHON
388
+ end
389
+
390
+ def generate_feather_selection_code
391
+ threshold = options[:bg_threshold] || 0.0
392
+ return "# No feathering" if threshold <= 0
393
+
394
+ <<~PYTHON.chomp
395
+ # Feather selection
396
+ print(f"Feathering selection by #{threshold} pixels...")
397
+ feather_proc = pdb.lookup_procedure('gimp-selection-feather')
398
+ if feather_proc:
399
+ config = feather_proc.create_config()
400
+ config.set_property('image', img)
401
+ config.set_property('radius', #{threshold})
402
+ feather_proc.run(config)
403
+ print("Selection feathered")
404
+ PYTHON
405
+ end
406
+
407
+ def execute_gimp_script(script_content, expected_output, operation_name)
408
+ script_file = File.join(Dir.tmpdir, "gimp_script_#{Time.now.to_i}_#{rand(10000)}.py")
409
+ log_file = File.join(Dir.tmpdir, "gimp_log_#{Time.now.to_i}_#{rand(10000)}.txt")
410
+
411
+ begin
412
+ File.write(script_file, script_content)
413
+
414
+ if options[:debug]
415
+ Utils::OutputFormatter.indent("DEBUG: Script file: #{script_file}")
416
+ Utils::OutputFormatter.indent("DEBUG: Log file: #{log_file}")
417
+ Utils::OutputFormatter.indent("DEBUG: Expected output: #{expected_output}")
418
+ end
419
+
420
+ # Build GIMP command based on platform
421
+ if Platform.windows?
422
+ execute_gimp_windows(script_file, log_file)
423
+ else
424
+ execute_gimp_unix(script_file, log_file)
425
+ end
426
+
427
+ gimp_output = ""
428
+ if File.exist?(log_file)
429
+ gimp_output = File.read(log_file)
430
+ end
431
+
432
+ # Filter GEGL warnings but keep actual errors and success messages
433
+ filtered_output = filter_gimp_output(gimp_output)
434
+
435
+ # Only show output if debug mode OR if there are actual messages (not just warnings)
436
+ if options[:debug] && !filtered_output.strip.empty?
437
+ Utils::OutputFormatter.indent("=== GIMP Output ===")
438
+ filtered_output.lines.each do |line|
439
+ Utils::OutputFormatter.indent(line.chomp)
440
+ end
441
+ Utils::OutputFormatter.indent("==================\n")
442
+ elsif !options[:debug] && has_important_messages?(gimp_output)
443
+ # Show important messages even without debug mode
444
+ Utils::OutputFormatter.indent("=== GIMP Messages ===")
445
+ filtered_output.lines.each do |line|
446
+ Utils::OutputFormatter.indent(line.chomp)
447
+ end
448
+ Utils::OutputFormatter.indent("====================\n")
449
+ end
450
+
451
+ Utils::FileHelper.validate_exists!(expected_output)
452
+
453
+ size = Utils::FileHelper.format_size(File.size(expected_output))
454
+ Utils::OutputFormatter.success("#{operation_name} complete (#{size})\n")
455
+
456
+ ensure
457
+ cleanup_temp_files(script_file, log_file) unless options[:keep_temp]
458
+ end
459
+ end
460
+
461
+ # Windows execution with GEGL warning suppression
462
+ def execute_gimp_windows(script_file, log_file)
463
+ batch_file = File.join(Dir.tmpdir, "gimp_run_#{Time.now.to_i}_#{rand(10000)}.bat")
464
+
465
+ batch_content = <<~BATCH
466
+ @echo off
467
+ REM Suppress GEGL debug output (known GIMP 3.x batch mode issue)
468
+ set GEGL_DEBUG=
469
+ "#{gimp_path}" --quit --batch-interpreter=python-fu-eval -b "exec(open(r'#{script_file}').read())" > "#{log_file}" 2>&1
470
+ exit /b %errorlevel%
471
+ BATCH
472
+
473
+ File.write(batch_file, batch_content)
474
+
475
+ if options[:debug]
476
+ Utils::OutputFormatter.indent("DEBUG: Batch file: #{batch_file}")
477
+ Utils::OutputFormatter.indent("DEBUG: Batch content:")
478
+ batch_content.lines.each do |line|
479
+ Utils::OutputFormatter.indent(" #{line.chomp}")
480
+ end
481
+ end
482
+
483
+ # Use Open3.capture3 with cmd.exe wrapper - this is the v0.6 approach that works
484
+ stdout, stderr, status = Open3.capture3("cmd.exe /c \"#{batch_file}\"")
485
+
486
+ if options[:debug]
487
+ Utils::OutputFormatter.indent("DEBUG: Command exit status: #{status.exitstatus}")
488
+ Utils::OutputFormatter.indent("DEBUG: stdout: #{stdout}") unless stdout.strip.empty?
489
+ Utils::OutputFormatter.indent("DEBUG: stderr: #{stderr}") unless stderr.strip.empty?
490
+ end
491
+
492
+ unless status.success?
493
+ log_content = File.exist?(log_file) ? File.read(log_file) : "No log file created"
494
+ raise ProcessingError, "GIMP processing failed (exit code: #{status.exitstatus})\n#{log_content}"
495
+ end
496
+
497
+ # Clean up batch file
498
+ File.delete(batch_file) if File.exist?(batch_file) && !options[:keep_temp]
499
+ end
500
+
501
+ # Unix execution (Linux/macOS)
502
+ def execute_gimp_unix(script_file, log_file)
503
+ cmd = "#{Utils::PathHelper.quote_path(gimp_path)} --quit --batch-interpreter=python-fu-eval -b \"exec(open(r'#{script_file}').read())\" > #{Utils::PathHelper.quote_path(log_file)} 2>&1"
504
+
505
+ if options[:debug]
506
+ Utils::OutputFormatter.indent("DEBUG: GIMP command: #{cmd}")
507
+ end
508
+
509
+ stdout, stderr, status = Open3.capture3(cmd)
510
+
511
+ if options[:debug]
512
+ Utils::OutputFormatter.indent("DEBUG: Command exit status: #{status.exitstatus}")
513
+ end
514
+
515
+ unless status.success?
516
+ log_content = File.exist?(log_file) ? File.read(log_file) : "No log file created"
517
+ raise ProcessingError, "GIMP processing failed (exit code: #{status.exitstatus})\n#{log_content}"
518
+ end
519
+ end
520
+
521
+ # Filter out known GEGL/GIMP warnings that are cosmetic
522
+ def filter_gimp_output(output)
523
+ lines = output.lines.reject do |line|
524
+ # Filter known GEGL buffer leak warnings (cosmetic in GIMP 3.x batch mode)
525
+ line.match?(/GEGL-WARNING/) ||
526
+ line.match?(/gegl_tile_cache_destroy/) ||
527
+ line.match?(/runtime check failed/) ||
528
+ line.match?(/To debug GeglBuffer leaks/) ||
529
+ line.match?(/GEGL_DEBUG.*buffer-alloc/) ||
530
+ line.match?(/GeglBuffers leaked/) ||
531
+ line.match?(/EEEEeEeek!/) ||
532
+ line.match?(/batch command executed successfully/) ||
533
+ line.strip.empty?
534
+ end
535
+ lines.join
536
+ end
537
+
538
+ # Check if output has important messages beyond warnings
539
+ def has_important_messages?(output)
540
+ filtered = filter_gimp_output(output)
541
+ # Has content other than SUCCESS messages
542
+ filtered.strip.split("\n").any? { |line| !line.match?(/SUCCESS/) && !line.strip.empty? }
543
+ end
544
+
545
+ # Preserve metadata from input file to output file
546
+ # GIMP strips metadata during export, so we need to copy it
547
+ def preserve_metadata(input_file, output_file)
548
+ # Read metadata from input file
549
+ input_metadata = MetadataManager.read(input_file)
550
+
551
+ return unless input_metadata # No metadata to preserve
552
+
553
+ if options[:debug]
554
+ Utils::OutputFormatter.indent("DEBUG: Preserving metadata from input file")
555
+ Utils::OutputFormatter.indent(" Columns: #{input_metadata[:columns]}")
556
+ Utils::OutputFormatter.indent(" Rows: #{input_metadata[:rows]}")
557
+ Utils::OutputFormatter.indent(" Frames: #{input_metadata[:frames]}")
558
+ end
559
+
560
+ # Create temporary file for re-embedding metadata
561
+ temp_file = output_file.sub('.png', '_temp_meta.png')
562
+ File.rename(output_file, temp_file)
563
+
564
+ # Re-embed metadata
565
+ MetadataManager.embed(
566
+ temp_file,
567
+ output_file,
568
+ columns: input_metadata[:columns],
569
+ rows: input_metadata[:rows],
570
+ frames: input_metadata[:frames],
571
+ debug: options[:debug]
572
+ )
573
+
574
+ # Clean up temp file
575
+ File.delete(temp_file) if File.exist?(temp_file)
576
+
577
+ if options[:debug]
578
+ Utils::OutputFormatter.indent("DEBUG: Metadata preserved in output file")
579
+ end
580
+ rescue StandardError => e
581
+ # If metadata preservation fails, keep the file but warn
582
+ if options[:debug]
583
+ Utils::OutputFormatter.warning("Could not preserve metadata: #{e.message}")
584
+ end
585
+ # Restore original file if temp exists
586
+ File.rename(temp_file, output_file) if defined?(temp_file) && File.exist?(temp_file) && !File.exist?(output_file)
587
+ end
588
+
589
+ def cleanup_temp_files(script_file, log_file)
590
+ batch_file = script_file.sub('.py', '.bat').sub('gimp_script', 'gimp_run')
591
+
592
+ [script_file, log_file, batch_file].each do |file|
593
+ File.delete(file) if File.exist?(file)
594
+ rescue StandardError => e
595
+ puts "Warning: Could not delete temp file #{file}: #{e.message}" if options[:debug]
596
+ end
597
+ end
598
+
599
+ # Map interpolation method names to GIMP interpolation type enum values
600
+ def map_interpolation_method(method)
601
+ # GIMP 3.x GimpInterpolationType enum values
602
+ case method.to_s.downcase
603
+ when 'none'
604
+ 'Gimp.InterpolationType.NONE'
605
+ when 'linear'
606
+ 'Gimp.InterpolationType.LINEAR'
607
+ when 'cubic'
608
+ 'Gimp.InterpolationType.CUBIC'
609
+ when 'nohalo'
610
+ 'Gimp.InterpolationType.NOHALO'
611
+ when 'lohalo'
612
+ 'Gimp.InterpolationType.LOHALO'
613
+ else
614
+ 'Gimp.InterpolationType.NOHALO' # Default to NoHalo for quality
615
+ end
616
+ end
617
+
618
+ # Apply unsharp mask using ImageMagick
619
+ def apply_sharpen_imagemagick(input_file)
620
+ radius = options[:sharpen_radius] || 2.0
621
+ gain = options[:sharpen_gain] || 0.5
622
+ threshold = options[:sharpen_threshold] || 0.03
623
+
624
+ output_file = Utils::FileHelper.output_filename(input_file, "sharpened")
625
+
626
+ Utils::OutputFormatter.indent("Applying unsharp mask (ImageMagick)...")
627
+ Utils::OutputFormatter.indent(" radius=#{radius}, gain=#{gain}, threshold=#{threshold}")
628
+
629
+ magick_cmd = Platform.imagemagick_convert_cmd
630
+
631
+ # Build ImageMagick unsharp command
632
+ # Format: -unsharp {radius}x{sigma}+{gain}+{threshold}
633
+ # sigma is typically radius * 0.5 for good results
634
+ sigma = radius * 0.5
635
+ unsharp_params = "#{radius}x#{sigma}+#{gain}+#{threshold}"
636
+
637
+ cmd = [
638
+ magick_cmd,
639
+ Utils::PathHelper.quote_path(input_file),
640
+ '-unsharp', unsharp_params,
641
+ Utils::PathHelper.quote_path(output_file)
642
+ ].join(' ')
643
+
644
+ if options[:debug]
645
+ Utils::OutputFormatter.indent("DEBUG: ImageMagick command: #{cmd}")
646
+ end
647
+
648
+ stdout, stderr, status = Open3.capture3(cmd)
649
+
650
+ unless status.success?
651
+ raise ProcessingError, "ImageMagick sharpen failed: #{stderr}"
652
+ end
653
+
654
+ Utils::FileHelper.validate_exists!(output_file)
655
+
656
+ # Preserve metadata
657
+ preserve_metadata(input_file, output_file)
658
+
659
+ Utils::OutputFormatter.success("Sharpening complete")
660
+
661
+ output_file
662
+ end
663
+ end
664
+ end