ruby_spriter 0.6.7.1 → 0.7.0.1

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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +3 -3
  3. data/CHANGELOG.md +1035 -524
  4. data/Gemfile +17 -17
  5. data/LICENSE +21 -21
  6. data/README.md +183 -950
  7. data/bin/ruby_spriter +20 -20
  8. data/lib/ruby_spriter/background_sampler.rb +140 -0
  9. data/lib/ruby_spriter/batch_processor.rb +268 -214
  10. data/lib/ruby_spriter/cell_cleanup_config.rb +21 -0
  11. data/lib/ruby_spriter/cell_cleanup_gimp_script.rb +123 -0
  12. data/lib/ruby_spriter/cell_cleanup_processor.rb +230 -0
  13. data/lib/ruby_spriter/cli.rb +676 -612
  14. data/lib/ruby_spriter/compression_manager.rb +101 -101
  15. data/lib/ruby_spriter/consolidator.rb +179 -179
  16. data/lib/ruby_spriter/dependency_checker.rb +224 -224
  17. data/lib/ruby_spriter/ghost_edge_cleaner.rb +164 -0
  18. data/lib/ruby_spriter/gimp_processor.rb +1188 -1058
  19. data/lib/ruby_spriter/metadata_manager.rb +117 -116
  20. data/lib/ruby_spriter/platform.rb +137 -137
  21. data/lib/ruby_spriter/processor.rb +1230 -891
  22. data/lib/ruby_spriter/smoke_detector.rb +223 -0
  23. data/lib/ruby_spriter/threshold_stepper.rb +227 -0
  24. data/lib/ruby_spriter/utils/file_helper.rb +82 -82
  25. data/lib/ruby_spriter/utils/image_helper.rb +16 -0
  26. data/lib/ruby_spriter/utils/output_formatter.rb +65 -65
  27. data/lib/ruby_spriter/utils/path_helper.rb +59 -59
  28. data/lib/ruby_spriter/utils/spritesheet_splitter.rb +86 -86
  29. data/lib/ruby_spriter/version.rb +6 -7
  30. data/lib/ruby_spriter/video_processor.rb +357 -139
  31. data/lib/ruby_spriter.rb +38 -34
  32. data/ruby_spriter.gemspec +44 -42
  33. data/spec/fixtures/centered_sprite_with_inner_bg.png +0 -0
  34. data/spec/fixtures/centered_sprite_with_inner_bg_inner_removed.png +0 -0
  35. data/spec/fixtures/centered_sprite_with_inner_bg_threshold_stepped.png +0 -0
  36. data/spec/fixtures/complex_background_sprite.png +0 -0
  37. data/spec/fixtures/death - bloodborne rekconing.mp4 +0 -0
  38. data/spec/fixtures/death - bloodborne rekconing_spritesheet-nobg-global.png +0 -0
  39. data/spec/fixtures/death - bloodborne rekconing_spritesheet.png +0 -0
  40. data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185027_431-nobg-global.png +0 -0
  41. data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185027_431.png +0 -0
  42. data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185125_738-nobg-global.png +0 -0
  43. data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185125_738.png +0 -0
  44. data/spec/fixtures/has_inner_bg.png +0 -0
  45. data/spec/fixtures/has_small_inner_bg.png +0 -0
  46. data/spec/fixtures/smoke_effect_sprite.png +0 -0
  47. data/spec/fixtures/spritesheet_with_metadata.png +0 -0
  48. data/spec/fixtures/test_sprite.png +0 -0
  49. data/spec/fixtures/test_sprite_smoke_processed.png +0 -0
  50. data/spec/fixtures/test_video_spritesheet.png +0 -0
  51. data/spec/fixtures/transparent_bg_sprite.png +0 -0
  52. data/spec/fixtures/walk_north_sprite-sheet.png +0 -0
  53. data/spec/integration/no_fuzzy_mode_spec.rb +100 -0
  54. data/spec/ruby_spriter/batch_processor_spec.rb +509 -200
  55. data/spec/ruby_spriter/cli_spec.rb +2026 -1892
  56. data/spec/ruby_spriter/compression_manager_spec.rb +157 -157
  57. data/spec/ruby_spriter/consolidator_spec.rb +538 -538
  58. data/spec/ruby_spriter/gimp_processor_spec.rb +523 -425
  59. data/spec/ruby_spriter/platform_spec.rb +92 -92
  60. data/spec/ruby_spriter/processor_spec.rb +911 -735
  61. data/spec/ruby_spriter/utils/file_helper_spec.rb +150 -150
  62. data/spec/ruby_spriter/utils/path_helper_spec.rb +78 -78
  63. data/spec/ruby_spriter/utils/spritesheet_splitter_spec.rb +104 -104
  64. data/spec/ruby_spriter/video_processor_spec.rb +346 -29
  65. data/spec/spec_helper.rb +41 -41
  66. data/spec/tmp/cli_test_output.png +0 -0
  67. data/spec/tmp/cli_test_output_20251105_114647_395.png +0 -0
  68. data/spec/tmp/combined_test.png +0 -0
  69. data/spec/tmp/compat_test.png +0 -0
  70. data/spec/tmp/compat_test_20251030_174233_361.png +0 -0
  71. data/spec/tmp/final_all_features.png +0 -0
  72. data/spec/tmp/final_test_all_features.png +0 -0
  73. data/spec/tmp/full_pipeline_test.png +0 -0
  74. data/spec/tmp/inner_test.png +0 -0
  75. data/spec/tmp/integration_test.png +0 -0
  76. data/spec/tmp/validation_test.png +0 -0
  77. data/spec/unit/background_sampler_spec.rb +132 -0
  78. data/spec/unit/cell_cleanup_config_spec.rb +32 -0
  79. data/spec/unit/cell_cleanup_gimp_script_spec.rb +59 -0
  80. data/spec/unit/cell_cleanup_processor_spec.rb +261 -0
  81. data/spec/unit/ghost_edge_cleaner_spec.rb +223 -0
  82. data/spec/unit/gimp_processor_single_point_selection_spec.rb +81 -0
  83. data/spec/unit/smoke_detector_spec.rb +246 -0
  84. data/spec/unit/threshold_stepper_spec.rb +195 -0
  85. metadata +56 -10
@@ -1,425 +1,523 @@
1
- # frozen_string_literal: true
2
-
3
- require 'spec_helper'
4
-
5
- RSpec.describe RubySpriter::GimpProcessor do
6
- let(:gimp_path) { '/usr/bin/gimp' }
7
- let(:test_image) { File.join(__dir__, '..', 'fixtures', 'spritesheet_4x2.png') }
8
-
9
- describe '#initialize' do
10
- it 'initializes with gimp_path and options' do
11
- processor = described_class.new(gimp_path, scale_percent: 50)
12
-
13
- expect(processor.gimp_path).to eq(gimp_path)
14
- expect(processor.options[:scale_percent]).to eq(50)
15
- end
16
-
17
- it 'initializes with empty options by default' do
18
- processor = described_class.new(gimp_path)
19
-
20
- expect(processor.gimp_path).to eq(gimp_path)
21
- expect(processor.options).to eq({})
22
- end
23
-
24
- it 'stores multiple options' do
25
- processor = described_class.new(gimp_path, {
26
- scale_percent: 50,
27
- remove_bg: true,
28
- scale_interpolation: 'nohalo',
29
- sharpen: true
30
- })
31
-
32
- expect(processor.options[:scale_percent]).to eq(50)
33
- expect(processor.options[:remove_bg]).to eq(true)
34
- expect(processor.options[:scale_interpolation]).to eq('nohalo')
35
- expect(processor.options[:sharpen]).to eq(true)
36
- end
37
- end
38
-
39
- describe '#determine_operations' do
40
- context 'with no operations requested' do
41
- it 'returns empty array' do
42
- processor = described_class.new(gimp_path, {})
43
- operations = processor.send(:determine_operations)
44
-
45
- expect(operations).to eq([])
46
- end
47
- end
48
-
49
- context 'with scale only' do
50
- it 'returns scale operation only' do
51
- processor = described_class.new(gimp_path, scale_percent: 50)
52
- operations = processor.send(:determine_operations)
53
-
54
- expect(operations).to eq([:scale_image])
55
- end
56
- end
57
-
58
- context 'with remove_bg only' do
59
- it 'returns remove_background operation only' do
60
- processor = described_class.new(gimp_path, remove_bg: true)
61
- operations = processor.send(:determine_operations)
62
-
63
- expect(operations).to eq([:remove_background])
64
- end
65
- end
66
-
67
- context 'with both scale and remove_bg (auto-optimization)' do
68
- it 'automatically does remove_bg first by default' do
69
- processor = described_class.new(gimp_path, {
70
- scale_percent: 50,
71
- remove_bg: true,
72
- operation_order: :scale_then_remove_bg # Default, but will be auto-optimized
73
- })
74
- operations = processor.send(:determine_operations)
75
-
76
- # Auto-optimization: remove_bg should come first
77
- expect(operations).to eq([:remove_background, :scale_image])
78
- end
79
-
80
- it 'respects explicit remove_bg_then_scale order' do
81
- processor = described_class.new(gimp_path, {
82
- scale_percent: 50,
83
- remove_bg: true,
84
- operation_order: :remove_bg_then_scale
85
- })
86
- operations = processor.send(:determine_operations)
87
-
88
- expect(operations).to eq([:remove_background, :scale_image])
89
- end
90
-
91
- it 'respects explicit scale_then_remove_bg order when not auto-optimized' do
92
- processor = described_class.new(gimp_path, {
93
- scale_percent: 50,
94
- remove_bg: true,
95
- operation_order: :scale_then_remove_bg,
96
- auto_optimize: false # If this were implemented
97
- })
98
- operations = processor.send(:determine_operations)
99
-
100
- # With auto-optimization, this still gets optimized
101
- expect(operations).to eq([:remove_background, :scale_image])
102
- end
103
- end
104
-
105
- context 'operation order edge cases' do
106
- it 'handles scale=nil, remove_bg=true' do
107
- processor = described_class.new(gimp_path, {
108
- scale_percent: nil,
109
- remove_bg: true
110
- })
111
- operations = processor.send(:determine_operations)
112
-
113
- expect(operations).to eq([:remove_background])
114
- end
115
-
116
- it 'handles scale=50, remove_bg=false' do
117
- processor = described_class.new(gimp_path, {
118
- scale_percent: 50,
119
- remove_bg: false
120
- })
121
- operations = processor.send(:determine_operations)
122
-
123
- expect(operations).to eq([:scale_image])
124
- end
125
- end
126
- end
127
-
128
- describe '#map_interpolation_method' do
129
- let(:processor) { described_class.new(gimp_path) }
130
-
131
- it 'maps "none" to GIMP NONE constant' do
132
- result = processor.send(:map_interpolation_method, 'none')
133
- expect(result).to eq('Gimp.InterpolationType.NONE')
134
- end
135
-
136
- it 'maps "linear" to GIMP LINEAR constant' do
137
- result = processor.send(:map_interpolation_method, 'linear')
138
- expect(result).to eq('Gimp.InterpolationType.LINEAR')
139
- end
140
-
141
- it 'maps "cubic" to GIMP CUBIC constant' do
142
- result = processor.send(:map_interpolation_method, 'cubic')
143
- expect(result).to eq('Gimp.InterpolationType.CUBIC')
144
- end
145
-
146
- it 'maps "nohalo" to GIMP NOHALO constant' do
147
- result = processor.send(:map_interpolation_method, 'nohalo')
148
- expect(result).to eq('Gimp.InterpolationType.NOHALO')
149
- end
150
-
151
- it 'maps "lohalo" to GIMP LOHALO constant' do
152
- result = processor.send(:map_interpolation_method, 'lohalo')
153
- expect(result).to eq('Gimp.InterpolationType.LOHALO')
154
- end
155
-
156
- it 'is case-insensitive' do
157
- expect(processor.send(:map_interpolation_method, 'NOHALO')).to eq('Gimp.InterpolationType.NOHALO')
158
- expect(processor.send(:map_interpolation_method, 'NoHalo')).to eq('Gimp.InterpolationType.NOHALO')
159
- expect(processor.send(:map_interpolation_method, 'LINEAR')).to eq('Gimp.InterpolationType.LINEAR')
160
- end
161
-
162
- it 'accepts symbol input' do
163
- result = processor.send(:map_interpolation_method, :nohalo)
164
- expect(result).to eq('Gimp.InterpolationType.NOHALO')
165
- end
166
-
167
- it 'defaults to NOHALO for unknown methods' do
168
- expect(processor.send(:map_interpolation_method, 'unknown')).to eq('Gimp.InterpolationType.NOHALO')
169
- expect(processor.send(:map_interpolation_method, 'foo')).to eq('Gimp.InterpolationType.NOHALO')
170
- expect(processor.send(:map_interpolation_method, '')).to eq('Gimp.InterpolationType.NOHALO')
171
- end
172
-
173
- it 'defaults to NOHALO for nil' do
174
- result = processor.send(:map_interpolation_method, nil)
175
- expect(result).to eq('Gimp.InterpolationType.NOHALO')
176
- end
177
- end
178
-
179
- describe '#filter_gimp_output' do
180
- let(:processor) { described_class.new(gimp_path) }
181
-
182
- it 'filters GEGL-WARNING lines' do
183
- output = "GEGL-WARNING: some warning\nUseful output\n"
184
- result = processor.send(:filter_gimp_output, output)
185
-
186
- expect(result).to eq("Useful output\n")
187
- end
188
-
189
- it 'filters gegl_tile_cache_destroy lines' do
190
- output = "gegl_tile_cache_destroy: leaked tiles\nUseful output\n"
191
- result = processor.send(:filter_gimp_output, output)
192
-
193
- expect(result).to eq("Useful output\n")
194
- end
195
-
196
- it 'filters runtime check failed lines' do
197
- output = "runtime check failed: something\nUseful output\n"
198
- result = processor.send(:filter_gimp_output, output)
199
-
200
- expect(result).to eq("Useful output\n")
201
- end
202
-
203
- it 'filters batch command executed successfully lines' do
204
- output = "batch command executed successfully\nUseful output\n"
205
- result = processor.send(:filter_gimp_output, output)
206
-
207
- expect(result).to eq("Useful output\n")
208
- end
209
-
210
- it 'filters GEGL_DEBUG buffer-alloc lines' do
211
- output = "GEGL_DEBUG: buffer-alloc details\nUseful output\n"
212
- result = processor.send(:filter_gimp_output, output)
213
-
214
- expect(result).to eq("Useful output\n")
215
- end
216
-
217
- it 'filters GeglBuffers leaked lines' do
218
- output = "GeglBuffers leaked: 5\nUseful output\n"
219
- result = processor.send(:filter_gimp_output, output)
220
-
221
- expect(result).to eq("Useful output\n")
222
- end
223
-
224
- it 'filters EEEEeEeek lines' do
225
- output = "EEEEeEeek! scary message\nUseful output\n"
226
- result = processor.send(:filter_gimp_output, output)
227
-
228
- expect(result).to eq("Useful output\n")
229
- end
230
-
231
- it 'filters empty lines' do
232
- output = "Line 1\n\nLine 2\n \nLine 3\n"
233
- result = processor.send(:filter_gimp_output, output)
234
-
235
- expect(result).to eq("Line 1\nLine 2\nLine 3\n")
236
- end
237
-
238
- it 'filters multiple warning types in one output' do
239
- output = <<~OUTPUT
240
- GEGL-WARNING: warning 1
241
- Useful line 1
242
- gegl_tile_cache_destroy: leak
243
- Useful line 2
244
- runtime check failed
245
- batch command executed successfully
246
- Useful line 3
247
- OUTPUT
248
-
249
- result = processor.send(:filter_gimp_output, output)
250
-
251
- expect(result).to eq("Useful line 1\nUseful line 2\nUseful line 3\n")
252
- end
253
-
254
- it 'returns empty string when all lines are filtered' do
255
- output = <<~OUTPUT
256
- GEGL-WARNING: warning
257
- gegl_tile_cache_destroy: leak
258
- runtime check failed
259
- OUTPUT
260
-
261
- result = processor.send(:filter_gimp_output, output)
262
-
263
- expect(result).to eq("")
264
- end
265
-
266
- it 'preserves important error messages' do
267
- output = "Error: File not found\nGEGL-WARNING: some warning\n"
268
- result = processor.send(:filter_gimp_output, output)
269
-
270
- expect(result).to eq("Error: File not found\n")
271
- end
272
- end
273
-
274
- describe '#has_important_messages?' do
275
- let(:processor) { described_class.new(gimp_path) }
276
-
277
- it 'returns false for only filtered warnings' do
278
- output = <<~OUTPUT
279
- GEGL-WARNING: warning
280
- gegl_tile_cache_destroy: leak
281
- OUTPUT
282
-
283
- expect(processor.send(:has_important_messages?, output)).to be false
284
- end
285
-
286
- it 'returns false for empty output' do
287
- expect(processor.send(:has_important_messages?, "")).to be false
288
- end
289
-
290
- it 'returns false for only SUCCESS messages' do
291
- output = "SUCCESS: Operation completed\n"
292
-
293
- expect(processor.send(:has_important_messages?, output)).to be false
294
- end
295
-
296
- it 'returns false for SUCCESS messages mixed with filtered warnings' do
297
- output = <<~OUTPUT
298
- GEGL-WARNING: warning
299
- SUCCESS: Operation completed
300
- gegl_tile_cache_destroy: leak
301
- OUTPUT
302
-
303
- expect(processor.send(:has_important_messages?, output)).to be false
304
- end
305
-
306
- it 'returns true for error messages' do
307
- output = "Error: Something went wrong\n"
308
-
309
- expect(processor.send(:has_important_messages?, output)).to be true
310
- end
311
-
312
- it 'returns true for important messages mixed with warnings' do
313
- output = <<~OUTPUT
314
- GEGL-WARNING: warning
315
- Error: File not found
316
- gegl_tile_cache_destroy: leak
317
- OUTPUT
318
-
319
- expect(processor.send(:has_important_messages?, output)).to be true
320
- end
321
-
322
- it 'returns true for non-filtered, non-SUCCESS output' do
323
- output = "Processing image...\nDone\n"
324
-
325
- expect(processor.send(:has_important_messages?, output)).to be true
326
- end
327
- end
328
-
329
- describe 'script generation' do
330
- let(:processor) { described_class.new(gimp_path, scale_percent: 50, scale_interpolation: 'nohalo') }
331
- let(:input_file) { '/path/to/input.png' }
332
- let(:output_file) { '/path/to/output.png' }
333
-
334
- describe '#generate_scale_script' do
335
- it 'generates Python script with correct file paths' do
336
- script = processor.send(:generate_scale_script, input_file, output_file, 50)
337
-
338
- expect(script).to include('input.png')
339
- expect(script).to include('output.png')
340
- end
341
-
342
- it 'includes correct scale percentage' do
343
- script = processor.send(:generate_scale_script, input_file, output_file, 50)
344
-
345
- # Percent is embedded directly and divided by 100 in Python: int(w * 50 / 100.0)
346
- expect(script).to include('* 50 / 100.0')
347
- end
348
-
349
- it 'includes correct interpolation method' do
350
- script = processor.send(:generate_scale_script, input_file, output_file, 50)
351
-
352
- expect(script).to include('Gimp.InterpolationType.NOHALO')
353
- end
354
-
355
- it 'uses correct GIMP 3.x API' do
356
- script = processor.send(:generate_scale_script, input_file, output_file, 50)
357
-
358
- expect(script).to include('Gimp.file_load')
359
- expect(script).to include('gimp-context-set-interpolation')
360
- expect(script).to include('gimp-layer-scale')
361
- end
362
-
363
- it 'handles different scale percentages' do
364
- script_25 = processor.send(:generate_scale_script, input_file, output_file, 25)
365
- script_75 = processor.send(:generate_scale_script, input_file, output_file, 75)
366
-
367
- expect(script_25).to include('* 25 / 100.0')
368
- expect(script_75).to include('* 75 / 100.0')
369
- end
370
- end
371
-
372
- describe '#generate_remove_bg_script' do
373
- context 'with fuzzy select (default)' do
374
- let(:processor_fuzzy) { described_class.new(gimp_path, remove_bg: true, fuzzy_select: true) }
375
-
376
- it 'generates script with fuzzy select' do
377
- script = processor_fuzzy.send(:generate_remove_bg_script, input_file, output_file)
378
-
379
- expect(script).to include('gimp-image-select-contiguous-color')
380
- end
381
-
382
- it 'samples all four corners' do
383
- script = processor_fuzzy.send(:generate_remove_bg_script, input_file, output_file)
384
-
385
- # The procedure is looked up once, then used in a loop for all 4 corners
386
- expect(script).to include('gimp-image-select-contiguous-color')
387
- expect(script).to include('for i, (x, y) in enumerate(corners):')
388
- # Verify corners array has 4 entries
389
- expect(script).to include('(0, 0)') # Top-left
390
- expect(script).to include('(w-1, 0)') # Top-right
391
- expect(script).to include('(0, h-1)') # Bottom-left
392
- expect(script).to include('(w-1, h-1)') # Bottom-right
393
- end
394
- end
395
-
396
- context 'with global color select' do
397
- let(:processor_global) { described_class.new(gimp_path, remove_bg: true, fuzzy_select: false) }
398
-
399
- it 'generates script with global color select' do
400
- script = processor_global.send(:generate_remove_bg_script, input_file, output_file)
401
-
402
- expect(script).to include('gimp-image-select-color')
403
- end
404
- end
405
-
406
- it 'includes file paths' do
407
- processor_bg = described_class.new(gimp_path, remove_bg: true)
408
- script = processor_bg.send(:generate_remove_bg_script, input_file, output_file)
409
-
410
- expect(script).to include('input.png')
411
- expect(script).to include('output.png')
412
- end
413
-
414
- it 'uses correct GIMP 3.x API' do
415
- processor_bg = described_class.new(gimp_path, remove_bg: true)
416
- script = processor_bg.send(:generate_remove_bg_script, input_file, output_file)
417
-
418
- expect(script).to include('Gimp.file_load')
419
- # Background removal selects corners directly (no inversion needed)
420
- expect(script).to include('gimp-drawable-edit-clear')
421
- expect(script).to include('gimp-selection-none')
422
- end
423
- end
424
- end
425
- end
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe RubySpriter::GimpProcessor do
6
+ let(:gimp_path) { '/usr/bin/gimp' }
7
+ let(:test_image) { File.join(__dir__, '..', 'fixtures', 'spritesheet_4x2.png') }
8
+
9
+ describe '#initialize' do
10
+ it 'initializes with gimp_path and options' do
11
+ processor = described_class.new(gimp_path, scale_percent: 50)
12
+
13
+ expect(processor.gimp_path).to eq(gimp_path)
14
+ expect(processor.options[:scale_percent]).to eq(50)
15
+ end
16
+
17
+ it 'initializes with empty options by default' do
18
+ processor = described_class.new(gimp_path)
19
+
20
+ expect(processor.gimp_path).to eq(gimp_path)
21
+ expect(processor.options).to eq({})
22
+ end
23
+
24
+ it 'stores multiple options' do
25
+ processor = described_class.new(gimp_path, {
26
+ scale_percent: 50,
27
+ remove_bg: true,
28
+ scale_interpolation: 'nohalo',
29
+ sharpen: true
30
+ })
31
+
32
+ expect(processor.options[:scale_percent]).to eq(50)
33
+ expect(processor.options[:remove_bg]).to eq(true)
34
+ expect(processor.options[:scale_interpolation]).to eq('nohalo')
35
+ expect(processor.options[:sharpen]).to eq(true)
36
+ end
37
+ end
38
+
39
+ describe '#determine_operations' do
40
+ context 'with no operations requested' do
41
+ it 'returns empty array' do
42
+ processor = described_class.new(gimp_path, {})
43
+ operations = processor.send(:determine_operations)
44
+
45
+ expect(operations).to eq([])
46
+ end
47
+ end
48
+
49
+ context 'with scale only' do
50
+ it 'returns scale operation only' do
51
+ processor = described_class.new(gimp_path, scale_percent: 50)
52
+ operations = processor.send(:determine_operations)
53
+
54
+ expect(operations).to eq([:scale_image])
55
+ end
56
+ end
57
+
58
+ context 'with remove_bg only' do
59
+ it 'returns remove_background operation only' do
60
+ processor = described_class.new(gimp_path, remove_bg: true)
61
+ operations = processor.send(:determine_operations)
62
+
63
+ expect(operations).to eq([:remove_background])
64
+ end
65
+ end
66
+
67
+ context 'with both scale and remove_bg (auto-optimization)' do
68
+ it 'automatically does remove_bg first by default' do
69
+ processor = described_class.new(gimp_path, {
70
+ scale_percent: 50,
71
+ remove_bg: true,
72
+ operation_order: :scale_then_remove_bg # Default, but will be auto-optimized
73
+ })
74
+ operations = processor.send(:determine_operations)
75
+
76
+ # Auto-optimization: remove_bg should come first
77
+ expect(operations).to eq([:remove_background, :scale_image])
78
+ end
79
+
80
+ it 'respects explicit remove_bg_then_scale order' do
81
+ processor = described_class.new(gimp_path, {
82
+ scale_percent: 50,
83
+ remove_bg: true,
84
+ operation_order: :remove_bg_then_scale
85
+ })
86
+ operations = processor.send(:determine_operations)
87
+
88
+ expect(operations).to eq([:remove_background, :scale_image])
89
+ end
90
+
91
+ it 'respects explicit scale_then_remove_bg order when not auto-optimized' do
92
+ processor = described_class.new(gimp_path, {
93
+ scale_percent: 50,
94
+ remove_bg: true,
95
+ operation_order: :scale_then_remove_bg,
96
+ auto_optimize: false # If this were implemented
97
+ })
98
+ operations = processor.send(:determine_operations)
99
+
100
+ # With auto-optimization, this still gets optimized
101
+ expect(operations).to eq([:remove_background, :scale_image])
102
+ end
103
+ end
104
+
105
+ context 'operation order edge cases' do
106
+ it 'handles scale=nil, remove_bg=true' do
107
+ processor = described_class.new(gimp_path, {
108
+ scale_percent: nil,
109
+ remove_bg: true
110
+ })
111
+ operations = processor.send(:determine_operations)
112
+
113
+ expect(operations).to eq([:remove_background])
114
+ end
115
+
116
+ it 'handles scale=50, remove_bg=false' do
117
+ processor = described_class.new(gimp_path, {
118
+ scale_percent: 50,
119
+ remove_bg: false
120
+ })
121
+ operations = processor.send(:determine_operations)
122
+
123
+ expect(operations).to eq([:scale_image])
124
+ end
125
+ end
126
+ end
127
+
128
+ describe '#map_interpolation_method' do
129
+ let(:processor) { described_class.new(gimp_path) }
130
+
131
+ it 'maps "none" to GIMP NONE constant' do
132
+ result = processor.send(:map_interpolation_method, 'none')
133
+ expect(result).to eq('Gimp.InterpolationType.NONE')
134
+ end
135
+
136
+ it 'maps "linear" to GIMP LINEAR constant' do
137
+ result = processor.send(:map_interpolation_method, 'linear')
138
+ expect(result).to eq('Gimp.InterpolationType.LINEAR')
139
+ end
140
+
141
+ it 'maps "cubic" to GIMP CUBIC constant' do
142
+ result = processor.send(:map_interpolation_method, 'cubic')
143
+ expect(result).to eq('Gimp.InterpolationType.CUBIC')
144
+ end
145
+
146
+ it 'maps "nohalo" to GIMP NOHALO constant' do
147
+ result = processor.send(:map_interpolation_method, 'nohalo')
148
+ expect(result).to eq('Gimp.InterpolationType.NOHALO')
149
+ end
150
+
151
+ it 'maps "lohalo" to GIMP LOHALO constant' do
152
+ result = processor.send(:map_interpolation_method, 'lohalo')
153
+ expect(result).to eq('Gimp.InterpolationType.LOHALO')
154
+ end
155
+
156
+ it 'is case-insensitive' do
157
+ expect(processor.send(:map_interpolation_method, 'NOHALO')).to eq('Gimp.InterpolationType.NOHALO')
158
+ expect(processor.send(:map_interpolation_method, 'NoHalo')).to eq('Gimp.InterpolationType.NOHALO')
159
+ expect(processor.send(:map_interpolation_method, 'LINEAR')).to eq('Gimp.InterpolationType.LINEAR')
160
+ end
161
+
162
+ it 'accepts symbol input' do
163
+ result = processor.send(:map_interpolation_method, :nohalo)
164
+ expect(result).to eq('Gimp.InterpolationType.NOHALO')
165
+ end
166
+
167
+ it 'defaults to NOHALO for unknown methods' do
168
+ expect(processor.send(:map_interpolation_method, 'unknown')).to eq('Gimp.InterpolationType.NOHALO')
169
+ expect(processor.send(:map_interpolation_method, 'foo')).to eq('Gimp.InterpolationType.NOHALO')
170
+ expect(processor.send(:map_interpolation_method, '')).to eq('Gimp.InterpolationType.NOHALO')
171
+ end
172
+
173
+ it 'defaults to NOHALO for nil' do
174
+ result = processor.send(:map_interpolation_method, nil)
175
+ expect(result).to eq('Gimp.InterpolationType.NOHALO')
176
+ end
177
+ end
178
+
179
+ describe '#filter_gimp_output' do
180
+ let(:processor) { described_class.new(gimp_path) }
181
+
182
+ it 'filters GEGL-WARNING lines' do
183
+ output = "GEGL-WARNING: some warning\nUseful output\n"
184
+ result = processor.send(:filter_gimp_output, output)
185
+
186
+ expect(result).to eq("Useful output\n")
187
+ end
188
+
189
+ it 'filters gegl_tile_cache_destroy lines' do
190
+ output = "gegl_tile_cache_destroy: leaked tiles\nUseful output\n"
191
+ result = processor.send(:filter_gimp_output, output)
192
+
193
+ expect(result).to eq("Useful output\n")
194
+ end
195
+
196
+ it 'filters runtime check failed lines' do
197
+ output = "runtime check failed: something\nUseful output\n"
198
+ result = processor.send(:filter_gimp_output, output)
199
+
200
+ expect(result).to eq("Useful output\n")
201
+ end
202
+
203
+ it 'filters batch command executed successfully lines' do
204
+ output = "batch command executed successfully\nUseful output\n"
205
+ result = processor.send(:filter_gimp_output, output)
206
+
207
+ expect(result).to eq("Useful output\n")
208
+ end
209
+
210
+ it 'filters GEGL_DEBUG buffer-alloc lines' do
211
+ output = "GEGL_DEBUG: buffer-alloc details\nUseful output\n"
212
+ result = processor.send(:filter_gimp_output, output)
213
+
214
+ expect(result).to eq("Useful output\n")
215
+ end
216
+
217
+ it 'filters GeglBuffers leaked lines' do
218
+ output = "GeglBuffers leaked: 5\nUseful output\n"
219
+ result = processor.send(:filter_gimp_output, output)
220
+
221
+ expect(result).to eq("Useful output\n")
222
+ end
223
+
224
+ it 'filters EEEEeEeek lines' do
225
+ output = "EEEEeEeek! scary message\nUseful output\n"
226
+ result = processor.send(:filter_gimp_output, output)
227
+
228
+ expect(result).to eq("Useful output\n")
229
+ end
230
+
231
+ it 'filters empty lines' do
232
+ output = "Line 1\n\nLine 2\n \nLine 3\n"
233
+ result = processor.send(:filter_gimp_output, output)
234
+
235
+ expect(result).to eq("Line 1\nLine 2\nLine 3\n")
236
+ end
237
+
238
+ it 'filters multiple warning types in one output' do
239
+ output = <<~OUTPUT
240
+ GEGL-WARNING: warning 1
241
+ Useful line 1
242
+ gegl_tile_cache_destroy: leak
243
+ Useful line 2
244
+ runtime check failed
245
+ batch command executed successfully
246
+ Useful line 3
247
+ OUTPUT
248
+
249
+ result = processor.send(:filter_gimp_output, output)
250
+
251
+ expect(result).to eq("Useful line 1\nUseful line 2\nUseful line 3\n")
252
+ end
253
+
254
+ it 'returns empty string when all lines are filtered' do
255
+ output = <<~OUTPUT
256
+ GEGL-WARNING: warning
257
+ gegl_tile_cache_destroy: leak
258
+ runtime check failed
259
+ OUTPUT
260
+
261
+ result = processor.send(:filter_gimp_output, output)
262
+
263
+ expect(result).to eq("")
264
+ end
265
+
266
+ it 'preserves important error messages' do
267
+ output = "Error: File not found\nGEGL-WARNING: some warning\n"
268
+ result = processor.send(:filter_gimp_output, output)
269
+
270
+ expect(result).to eq("Error: File not found\n")
271
+ end
272
+ end
273
+
274
+ describe '#has_important_messages?' do
275
+ let(:processor) { described_class.new(gimp_path) }
276
+
277
+ it 'returns false for only filtered warnings' do
278
+ output = <<~OUTPUT
279
+ GEGL-WARNING: warning
280
+ gegl_tile_cache_destroy: leak
281
+ OUTPUT
282
+
283
+ expect(processor.send(:has_important_messages?, output)).to be false
284
+ end
285
+
286
+ it 'returns false for empty output' do
287
+ expect(processor.send(:has_important_messages?, "")).to be false
288
+ end
289
+
290
+ it 'returns false for only SUCCESS messages' do
291
+ output = "SUCCESS: Operation completed\n"
292
+
293
+ expect(processor.send(:has_important_messages?, output)).to be false
294
+ end
295
+
296
+ it 'returns false for SUCCESS messages mixed with filtered warnings' do
297
+ output = <<~OUTPUT
298
+ GEGL-WARNING: warning
299
+ SUCCESS: Operation completed
300
+ gegl_tile_cache_destroy: leak
301
+ OUTPUT
302
+
303
+ expect(processor.send(:has_important_messages?, output)).to be false
304
+ end
305
+
306
+ it 'returns true for error messages' do
307
+ output = "Error: Something went wrong\n"
308
+
309
+ expect(processor.send(:has_important_messages?, output)).to be true
310
+ end
311
+
312
+ it 'returns true for important messages mixed with warnings' do
313
+ output = <<~OUTPUT
314
+ GEGL-WARNING: warning
315
+ Error: File not found
316
+ gegl_tile_cache_destroy: leak
317
+ OUTPUT
318
+
319
+ expect(processor.send(:has_important_messages?, output)).to be true
320
+ end
321
+
322
+ it 'returns true for non-filtered, non-SUCCESS output' do
323
+ output = "Processing image...\nDone\n"
324
+
325
+ expect(processor.send(:has_important_messages?, output)).to be true
326
+ end
327
+ end
328
+
329
+ describe 'script generation' do
330
+ let(:processor) { described_class.new(gimp_path, scale_percent: 50, scale_interpolation: 'nohalo') }
331
+ let(:input_file) { '/path/to/input.png' }
332
+ let(:output_file) { '/path/to/output.png' }
333
+
334
+ describe '#generate_scale_script' do
335
+ it 'generates Python script with correct file paths' do
336
+ script = processor.send(:generate_scale_script, input_file, output_file, 50)
337
+
338
+ expect(script).to include('input.png')
339
+ expect(script).to include('output.png')
340
+ end
341
+
342
+ it 'includes correct scale percentage' do
343
+ script = processor.send(:generate_scale_script, input_file, output_file, 50)
344
+
345
+ # Percent is embedded directly and divided by 100 in Python: int(w * 50 / 100.0)
346
+ expect(script).to include('* 50 / 100.0')
347
+ end
348
+
349
+ it 'includes correct interpolation method' do
350
+ script = processor.send(:generate_scale_script, input_file, output_file, 50)
351
+
352
+ expect(script).to include('Gimp.InterpolationType.NOHALO')
353
+ end
354
+
355
+ it 'uses correct GIMP 3.x API' do
356
+ script = processor.send(:generate_scale_script, input_file, output_file, 50)
357
+
358
+ expect(script).to include('Gimp.file_load')
359
+ expect(script).to include('gimp-context-set-interpolation')
360
+ expect(script).to include('gimp-layer-scale')
361
+ end
362
+
363
+ it 'handles different scale percentages' do
364
+ script_25 = processor.send(:generate_scale_script, input_file, output_file, 25)
365
+ script_75 = processor.send(:generate_scale_script, input_file, output_file, 75)
366
+
367
+ expect(script_25).to include('* 25 / 100.0')
368
+ expect(script_75).to include('* 75 / 100.0')
369
+ end
370
+ end
371
+
372
+ describe '#generate_remove_bg_script' do
373
+ context 'with fuzzy select (default)' do
374
+ let(:processor_fuzzy) { described_class.new(gimp_path, remove_bg: true, fuzzy_select: true) }
375
+
376
+ it 'generates script with fuzzy select' do
377
+ script = processor_fuzzy.send(:generate_remove_bg_script, input_file, output_file)
378
+
379
+ expect(script).to include('gimp-image-select-contiguous-color')
380
+ end
381
+
382
+ it 'samples from single interior point (not corners)' do
383
+ script = processor_fuzzy.send(:generate_remove_bg_script, input_file, output_file)
384
+
385
+ # Should use fuzzy select procedure
386
+ expect(script).to include('gimp-image-select-contiguous-color')
387
+ # Should sample from single interior point (5, 5) to avoid edge artifacts
388
+ expect(script).to include('x = 5')
389
+ expect(script).to include('y = 5')
390
+ # Should NOT loop through multiple corners
391
+ expect(script).not_to include('for i, (x, y) in enumerate(corners):')
392
+ # Should NOT use ADD operation (only REPLACE)
393
+ expect(script).not_to include('Gimp.ChannelOps.ADD')
394
+ end
395
+ end
396
+
397
+ context 'with global color select' do
398
+ let(:processor_global) { described_class.new(gimp_path, remove_bg: true, fuzzy_select: false) }
399
+
400
+ it 'generates script with global color select' do
401
+ script = processor_global.send(:generate_remove_bg_script, input_file, output_file)
402
+
403
+ expect(script).to include('gimp-image-select-color')
404
+ end
405
+ end
406
+
407
+ describe '#generate_global_select_code' do
408
+ let(:processor_with_threshold) do
409
+ described_class.new(gimp_path, remove_bg: true, fuzzy_select: false, bg_threshold: 5.0)
410
+ end
411
+
412
+ it 'includes threshold parameter in global color select script' do
413
+ script = processor_with_threshold.send(:generate_global_select_code)
414
+
415
+ # Should set threshold via context API (0-255 range)
416
+ expect(script).to include("Gimp.context_set_sample_threshold_int(int(5.0))")
417
+ expect(script).to include('Threshold: 5.0')
418
+ end
419
+
420
+ it 'uses default threshold of 15.0 when not specified' do
421
+ processor_default = described_class.new(gimp_path, remove_bg: true, fuzzy_select: false)
422
+ script = processor_default.send(:generate_global_select_code)
423
+
424
+ # Should use default threshold via context API
425
+ expect(script).to include("Gimp.context_set_sample_threshold_int(int(15.0))")
426
+ expect(script).to include('Threshold: 15.0')
427
+ end
428
+
429
+ it 'uses 0.0 threshold for exact color matching when threshold is 0' do
430
+ processor_exact = described_class.new(gimp_path, remove_bg: true, fuzzy_select: false, bg_threshold: 0.0)
431
+ script = processor_exact.send(:generate_global_select_code)
432
+
433
+ expect(script).to include("Gimp.context_set_sample_threshold_int(int(0.0))")
434
+ expect(script).to include('Threshold: 0.0')
435
+ end
436
+ end
437
+
438
+ describe '#generate_feather_selection_code' do
439
+ it 'returns no feathering when feather_radius is not set' do
440
+ processor = described_class.new(gimp_path, remove_bg: true)
441
+ code = processor.send(:generate_feather_selection_code)
442
+
443
+ expect(code).to include('# No feathering')
444
+ end
445
+
446
+ it 'returns no feathering when feather_radius is 0' do
447
+ processor = described_class.new(gimp_path, remove_bg: true, feather_radius: 0.0)
448
+ code = processor.send(:generate_feather_selection_code)
449
+
450
+ expect(code).to include('# No feathering')
451
+ end
452
+
453
+ it 'includes feathering code when feather_radius is set' do
454
+ processor = described_class.new(gimp_path, remove_bg: true, feather_radius: 2.0)
455
+ code = processor.send(:generate_feather_selection_code)
456
+
457
+ expect(code).to include('gimp-selection-feather')
458
+ expect(code).to include('2.0')
459
+ end
460
+
461
+ it 'uses feather_radius parameter, not bg_threshold' do
462
+ processor = described_class.new(gimp_path, remove_bg: true, bg_threshold: 25.0, feather_radius: 3.0)
463
+ code = processor.send(:generate_feather_selection_code)
464
+
465
+ # Should use feather_radius (3.0), not bg_threshold (25.0)
466
+ expect(code).to include('3.0')
467
+ expect(code).not_to include('25.0')
468
+ end
469
+ end
470
+
471
+ describe 'threshold and feathering separation' do
472
+ it 'uses bg_threshold for color tolerance in global select' do
473
+ processor = described_class.new(gimp_path, remove_bg: true, fuzzy_select: false, bg_threshold: 20.0)
474
+ code = processor.send(:generate_global_select_code)
475
+
476
+ expect(code).to include("Gimp.context_set_sample_threshold_int(int(20.0))")
477
+ expect(code).to include('Threshold: 20.0')
478
+ end
479
+
480
+ it 'uses bg_threshold for color tolerance in fuzzy select' do
481
+ processor = described_class.new(gimp_path, remove_bg: true, fuzzy_select: true, bg_threshold: 10.0)
482
+ code = processor.send(:generate_fuzzy_select_code)
483
+
484
+ # Fuzzy select now supports threshold via context API
485
+ expect(code).to include("Gimp.context_set_sample_threshold_int(int(10.0))")
486
+ expect(code).to include('Threshold: 10.0')
487
+ end
488
+
489
+ it 'uses feather_radius for edge softening, independent of threshold' do
490
+ processor = described_class.new(gimp_path, remove_bg: true, bg_threshold: 15.0, feather_radius: 2.5)
491
+
492
+ select_code = processor.send(:generate_global_select_code)
493
+ feather_code = processor.send(:generate_feather_selection_code)
494
+
495
+ # Threshold used for color selection (0-255 range)
496
+ expect(select_code).to include('Gimp.context_set_sample_threshold_int(int(15.0))')
497
+ expect(select_code).to include('Threshold: 15.0')
498
+
499
+ # Feather radius used for edge softening
500
+ expect(feather_code).to include('2.5')
501
+ end
502
+ end
503
+
504
+ it 'includes file paths' do
505
+ processor_bg = described_class.new(gimp_path, remove_bg: true)
506
+ script = processor_bg.send(:generate_remove_bg_script, input_file, output_file)
507
+
508
+ expect(script).to include('input.png')
509
+ expect(script).to include('output.png')
510
+ end
511
+
512
+ it 'uses correct GIMP 3.x API' do
513
+ processor_bg = described_class.new(gimp_path, remove_bg: true)
514
+ script = processor_bg.send(:generate_remove_bg_script, input_file, output_file)
515
+
516
+ expect(script).to include('Gimp.file_load')
517
+ # Background removal selects corners directly (no inversion needed)
518
+ expect(script).to include('gimp-drawable-edit-clear')
519
+ expect(script).to include('gimp-selection-none')
520
+ end
521
+ end
522
+ end
523
+ end