retouch 0.3.0 → 0.3.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.
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Retouch
4
+ module Kernels
5
+ module LUT
6
+ module_function
7
+
8
+ def apply(image, operation, value = nil, amount: 1.0)
9
+ bytes = image.bytes.dup
10
+ number = value.nil? || operation.to_sym == :tint ? nil : Float(value)
11
+ raise ArgumentError, "value must be finite" if number && !number.finite?
12
+
13
+ case operation.to_sym
14
+ when :grayscale
15
+ bytes.bytes.each_slice(4).with_index do |(red, green, blue, _alpha), index|
16
+ gray = (((red * 2126) + (green * 7152) + (blue * 722)) / 10_000.0).round
17
+ bytes.setbyte(index * 4, gray)
18
+ bytes.setbyte((index * 4) + 1, gray)
19
+ bytes.setbyte((index * 4) + 2, gray)
20
+ end
21
+ when :invert
22
+ lut = Array.new(256) { |channel| 255 - channel }
23
+ transform_rgb(bytes) { |channel| lut[channel] }
24
+ when :brightness
25
+ raise ArgumentError, "brightness must be between -255 and 255" unless number&.between?(-255, 255)
26
+
27
+ lut = Array.new(256) { |channel| (channel + number).round.clamp(0, 255) }
28
+ transform_rgb(bytes) { |channel| lut[channel] }
29
+ when :contrast
30
+ raise ArgumentError, "contrast must be between 0 and 10" unless number&.between?(0, 10)
31
+
32
+ lut = Array.new(256) { |channel| (((channel - 127.5) * number) + 127.5).round.clamp(0, 255) }
33
+ transform_rgb(bytes) { |channel| lut[channel] }
34
+ when :gamma
35
+ raise ArgumentError, "gamma must be positive and at most 10" unless number&.positive? && number <= 10
36
+
37
+ lut = Array.new(256) { |channel| (255 * ((channel / 255.0)**(1.0 / number))).round }
38
+ transform_rgb(bytes) { |channel| lut[channel] }
39
+ when :saturate
40
+ raise ArgumentError, "saturation must be between 0 and 10" unless number&.between?(0, 10)
41
+
42
+ bytes.bytes.each_slice(4).with_index do |(red, green, blue, _alpha), index|
43
+ gray = (red * 0.2126) + (green * 0.7152) + (blue * 0.0722)
44
+ [red, green, blue].each_with_index do |channel, component|
45
+ bytes.setbyte((index * 4) + component, (gray + ((channel - gray) * number)).round.clamp(0, 255))
46
+ end
47
+ end
48
+ when :tint
49
+ raise ArgumentError, "tint amount must be between 0 and 1" unless amount.is_a?(Numeric) && amount.finite? && amount.between?(0, 1)
50
+
51
+ tint = Tessel::Color.pack(value).bytes.first(3)
52
+ bytes.bytes.each_slice(4).with_index do |(red, green, blue, _alpha), index|
53
+ [red, green, blue].each_with_index do |channel, component|
54
+ bytes.setbyte((index * 4) + component, ((channel * (1 - amount)) + (tint[component] * amount)).round)
55
+ end
56
+ end
57
+ when :opacity
58
+ raise ArgumentError, "opacity must be between 0 and 1" unless number&.between?(0, 1)
59
+
60
+ (3...bytes.bytesize).step(4) { |index| bytes.setbyte(index, (bytes.getbyte(index) * number).round) }
61
+ else
62
+ raise ArgumentError, "unknown LUT operation: #{operation}"
63
+ end
64
+ Tessel::Image.from_rgba(image.width, image.height, bytes, metadata: image.metadata)
65
+ end
66
+
67
+ def transform_rgb(bytes)
68
+ bytes.bytesize.times do |index|
69
+ bytes.setbyte(index, yield(bytes.getbyte(index))) if index % 4 != 3
70
+ end
71
+ end
72
+ private_class_method :transform_rgb
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Retouch
4
+ module Operations
5
+ module_function
6
+
7
+ %i[grayscale invert brightness contrast gamma saturate opacity].each do |operation|
8
+ define_method(operation) do |image, value = nil|
9
+ Kernels::LUT.apply(image, operation, value)
10
+ end
11
+ end
12
+
13
+ def tint(image, color, amount: 1.0)
14
+ Kernels::LUT.apply(image, :tint, color, amount: Float(amount))
15
+ end
16
+
17
+ def quantize(image, colors: 256, dither: :none, palette: nil)
18
+ Kernels.quantize(image, colors:, dither:, palette:)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Retouch
4
+ module Operations
5
+ module_function
6
+
7
+ BLENDS = %i[normal multiply screen overlay darken lighten add].freeze
8
+
9
+ def overlay(image, source, x: nil, y: nil, gravity: :center, opacity: 1.0, mode: :normal)
10
+ source = ImageIO.read(source) unless source.is_a?(Tessel::Image)
11
+ opacity = Float(opacity)
12
+ mode = mode.to_sym
13
+ raise ArgumentError, "opacity must be between 0 and 1" unless opacity.finite? && opacity.between?(0, 1)
14
+ raise ArgumentError, "unknown blend mode: #{mode}" unless BLENDS.include?(mode)
15
+
16
+ offset_x, offset_y = Gravity.offset(image.width, image.height, source.width, source.height, gravity)
17
+ offset_x = Integer(x) unless x.nil?
18
+ offset_y = Integer(y) unless y.nil?
19
+ output = image.dup
20
+ source.height.times do |source_y|
21
+ source.width.times do |source_x|
22
+ target_x = source_x + offset_x
23
+ target_y = source_y + offset_y
24
+ next unless target_x.between?(0, image.width - 1) && target_y.between?(0, image.height - 1)
25
+
26
+ source_pixel = source[source_x, source_y]
27
+ source_pixel[3] = (source_pixel[3] * opacity).round
28
+ next if source_pixel[3].zero?
29
+
30
+ output[target_x, target_y] = blend_pixel(output[target_x, target_y], source_pixel, mode)
31
+ end
32
+ end
33
+ output
34
+ end
35
+
36
+ def watermark(image, source, **options)
37
+ overlay(image, source, **options)
38
+ end
39
+
40
+ def rect(image, x, y, width, height, color = "#ffffff", fill: false)
41
+ x = Integer(x)
42
+ y = Integer(y)
43
+ width = Integer(width)
44
+ height = Integer(height)
45
+ raise ArgumentError, "rectangle dimensions must be positive" unless width.positive? && height.positive?
46
+
47
+ output = image.dup
48
+ if fill
49
+ output.fill_rect(x, y, width, height, color, blend: :alpha)
50
+ else
51
+ output.fill_rect(x, y, width, 1, color, blend: :alpha)
52
+ output.fill_rect(x, y + height - 1, width, 1, color, blend: :alpha)
53
+ output.fill_rect(x, y, 1, height, color, blend: :alpha)
54
+ output.fill_rect(x + width - 1, y, 1, height, color, blend: :alpha)
55
+ end
56
+ output
57
+ end
58
+
59
+ def arrow(image, x1, y1, x2, y2, color = "#ffffff", width: 1, head: 8)
60
+ x1, y1, x2, y2 = [x1, y1, x2, y2].map { |value| Integer(value) }
61
+ width = Integer(width)
62
+ head = Float(head)
63
+ raise ArgumentError, "arrow width must be positive" unless width.positive?
64
+ raise ArgumentError, "arrow head length must be non-negative and finite" unless head.finite? && head >= 0
65
+
66
+ output = image.dup
67
+ draw_line(output, x1, y1, x2, y2, color, width)
68
+ distance = Math.hypot(x2 - x1, y2 - y1)
69
+ if head.positive? && distance.positive?
70
+ angle = Math.atan2(y2 - y1, x2 - x1)
71
+ [angle + (0.65 * Math::PI), angle - (0.65 * Math::PI)].each do |side|
72
+ draw_line(output, x2, y2, (x2 + (Math.cos(side) * head)).round, (y2 + (Math.sin(side) * head)).round, color, width)
73
+ end
74
+ end
75
+ output
76
+ end
77
+
78
+ def text(image, string, x: 0, y: 0, at: nil, size: 16, font: nil, color: "#ffffff", background: nil, padding: 4)
79
+ begin
80
+ require "glyphic"
81
+ rescue LoadError => e
82
+ raise Error, "text requires the glyphic gem (gem install glyphic)", cause: e
83
+ end
84
+ size = Integer(size)
85
+ raise ArgumentError, "font size must be positive" unless size.positive?
86
+
87
+ font_object = font ? Glyphic.load(font, size: Integer(size)) : Glyphic.default
88
+ rendered = font_object.render(String(string), color: Tessel::Color.pack(color).bytes)
89
+ if !font && size != font_object.line_height
90
+ scale = size.to_f / font_object.line_height
91
+ rendered = rendered.scale_nearest([(rendered.width * scale).round, 1].max, [(rendered.height * scale).round, 1].max)
92
+ end
93
+ text_width = rendered.width
94
+ text_height = rendered.height
95
+ padding = Integer(padding)
96
+ raise ArgumentError, "text padding must not be negative" if padding.negative?
97
+
98
+ if at
99
+ x, y = Gravity.offset(image.width, image.height, text_width + (padding * 2), text_height + (padding * 2), at)
100
+ x += padding
101
+ y += padding
102
+ else
103
+ x = Integer(x)
104
+ y = Integer(y)
105
+ end
106
+ output = image.dup
107
+ output.fill_rect(x - padding, y - padding, text_width + (padding * 2), text_height + (padding * 2), background, blend: :alpha) if background
108
+ output.blit(rendered, x, y)
109
+ output
110
+ end
111
+
112
+ def blend_pixel(destination, source, mode)
113
+ source_alpha = source[3] / 255.0
114
+ destination_alpha = destination[3] / 255.0
115
+ output_alpha = source_alpha + (destination_alpha * (1 - source_alpha))
116
+ return [0, 0, 0, 0] if output_alpha.zero?
117
+
118
+ rgb = 3.times.map do |channel|
119
+ source_color = source[channel] / 255.0
120
+ destination_color = destination[channel] / 255.0
121
+ blended = case mode
122
+ when :normal then source_color
123
+ when :multiply then source_color * destination_color
124
+ when :screen then 1 - ((1 - source_color) * (1 - destination_color))
125
+ when :overlay then destination_color <= 0.5 ? 2 * source_color * destination_color : 1 - (2 * (1 - source_color) * (1 - destination_color))
126
+ when :darken then [source_color, destination_color].min
127
+ when :lighten then [source_color, destination_color].max
128
+ when :add then [source_color + destination_color, 1].min
129
+ end
130
+ premultiplied = ((1 - source_alpha) * destination_alpha * destination_color) + ((1 - destination_alpha) * source_alpha * source_color) + (source_alpha * destination_alpha * blended)
131
+ (premultiplied / output_alpha * 255).round.clamp(0, 255)
132
+ end
133
+ [*rgb, (output_alpha * 255).round.clamp(0, 255)]
134
+ end
135
+ private_class_method :blend_pixel
136
+
137
+ def draw_line(image, x1, y1, x2, y2, color, width)
138
+ dx = (x2 - x1).abs
139
+ sx = x1 < x2 ? 1 : -1
140
+ dy = -(y2 - y1).abs
141
+ sy = y1 < y2 ? 1 : -1
142
+ error = dx + dy
143
+ loop do
144
+ image.fill_rect(x1 - (width / 2), y1 - (width / 2), width, width, color, blend: :alpha)
145
+ break if x1 == x2 && y1 == y2
146
+
147
+ doubled = 2 * error
148
+ if doubled >= dy
149
+ error += dy
150
+ x1 += sx
151
+ end
152
+ if doubled <= dx
153
+ error += dx
154
+ y1 += sy
155
+ end
156
+ end
157
+ end
158
+ private_class_method :draw_line
159
+ end
160
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Retouch
4
+ module Operations
5
+ module_function
6
+
7
+ def blur(image, sigma)
8
+ Kernels::Convolve.gaussian(image, sigma)
9
+ end
10
+
11
+ def sharpen(image, sigma, amount: 1.0)
12
+ amount = Float(amount)
13
+ raise ArgumentError, "sharpen amount must be between 0 and 10" unless amount.finite? && amount.between?(0, 10)
14
+
15
+ blurred = blur(image, sigma)
16
+ source = image.bytes
17
+ smooth = blurred.bytes
18
+ output = source.dup
19
+ (0...(image.width * image.height)).each do |index|
20
+ offset = index * 4
21
+ original_alpha = source.getbyte(offset + 3) / 255.0
22
+ smooth_alpha = smooth.getbyte(offset + 3) / 255.0
23
+ 3.times do |channel|
24
+ original = source.getbyte(offset + channel) * original_alpha
25
+ blurred_color = smooth.getbyte(offset + channel) * smooth_alpha
26
+ sharpened = original + (amount * (original - blurred_color))
27
+ color = original_alpha.positive? ? (sharpened / original_alpha).round.clamp(0, 255) : 0
28
+ output.setbyte(offset + channel, color)
29
+ end
30
+ end
31
+ Tessel::Image.from_rgba(image.width, image.height, output, metadata: image.metadata)
32
+ end
33
+
34
+ def pixelate(image, size)
35
+ size = Integer(size)
36
+ raise ArgumentError, "pixel size must be positive" unless size.positive?
37
+ return image.dup if image.width.zero? || image.height.zero?
38
+
39
+ output = image.dup
40
+ (0...image.height).step(size) do |y|
41
+ height = [size, image.height - y].min
42
+ (0...image.width).step(size) do |x|
43
+ width = [size, image.width - x].min
44
+ alpha_sum = red = green = blue = 0.0
45
+ (y...(y + height)).each do |py|
46
+ (x...(x + width)).each do |px|
47
+ color = image[px, py]
48
+ alpha = color[3] / 255.0
49
+ red += color[0] * alpha
50
+ green += color[1] * alpha
51
+ blue += color[2] * alpha
52
+ alpha_sum += color[3]
53
+ end
54
+ end
55
+ count = width * height
56
+ alpha = (alpha_sum / count).round
57
+ color = alpha_sum.positive? ? [(red * 255 / alpha_sum).round, (green * 255 / alpha_sum).round, (blue * 255 / alpha_sum).round, alpha] : [0, 0, 0, 0]
58
+ output.fill_rect(x, y, width, height, color, blend: :copy)
59
+ end
60
+ end
61
+ output
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Retouch
4
+ module Operations
5
+ module_function
6
+
7
+ def append(images, direction: :vertical, gap: 0, background: [0, 0, 0, 0])
8
+ images = validate_images(images)
9
+ direction = direction.to_sym
10
+ gap = Integer(gap)
11
+ raise ArgumentError, "direction must be vertical or horizontal" unless %i[vertical horizontal].include?(direction)
12
+ raise ArgumentError, "gap must not be negative" if gap.negative?
13
+
14
+ if direction == :vertical
15
+ width = images.map(&:width).max
16
+ height = images.sum(&:height) + (gap * (images.length - 1))
17
+ else
18
+ width = images.sum(&:width) + (gap * (images.length - 1))
19
+ height = images.map(&:height).max
20
+ end
21
+ output = Tessel::Image.new(width, height, fill: background)
22
+ x = y = 0
23
+ images.each do |image|
24
+ dx, dy = Gravity.offset(width, height, image.width, image.height, :center)
25
+ output.blit(image, direction == :vertical ? dx : x, direction == :vertical ? y : dy)
26
+ direction == :vertical ? y += image.height + gap : x += image.width + gap
27
+ end
28
+ output
29
+ end
30
+
31
+ def montage(images, columns: nil, cols: nil, gap: 0, background: [0, 0, 0, 0], labels: nil)
32
+ images = validate_images(images)
33
+ columns = Integer(columns || cols || Math.sqrt(images.length).ceil)
34
+ gap = Integer(gap)
35
+ raise ArgumentError, "columns must be positive" unless columns.positive?
36
+ raise ArgumentError, "gap must not be negative" if gap.negative?
37
+
38
+ column_widths = Array.new([images.length, columns].min, 0)
39
+ row_heights = Array.new((images.length.to_f / columns).ceil, 0)
40
+ images.each_with_index do |image, index|
41
+ column = index % columns
42
+ row = index / columns
43
+ column_widths[column] = [column_widths[column], image.width].max
44
+ row_heights[row] = [row_heights[row], image.height + (labels ? 15 : 0)].max
45
+ end
46
+ output = Tessel::Image.new(column_widths.sum + (gap * (column_widths.length - 1)), row_heights.sum + (gap * (row_heights.length - 1)), fill: background)
47
+ row_y = 0
48
+ row_heights.each_with_index do |row_height, row|
49
+ column_x = 0
50
+ column_widths.each_with_index do |column_width, column|
51
+ index = (row * columns) + column
52
+ break unless images[index]
53
+
54
+ image = images[index]
55
+ image_y = row_y + ((row_height - image.height - (labels ? 15 : 0)) / 2)
56
+ output.blit(image, column_x + ((column_width - image.width) / 2), image_y)
57
+ if labels
58
+ label = labels == true ? index.to_s : String(labels.fetch(index))
59
+ output.fill_rect(column_x, row_y + row_height - 15, column_width, 15, [0, 0, 0, 192], blend: :alpha)
60
+ output = text(output, label, x: column_x + 3, y: row_y + row_height - 12, size: 9, color: "#ffffff")
61
+ end
62
+ column_x += column_width + gap
63
+ end
64
+ row_y += row_height + gap
65
+ end
66
+ output
67
+ end
68
+
69
+ def spritesheet(images, max_width: 2048, gap: 0, background: [0, 0, 0, 0])
70
+ images = validate_images(images)
71
+ max_width = Integer(max_width)
72
+ gap = Integer(gap)
73
+ raise ArgumentError, "max_width must be positive" unless max_width.positive?
74
+ raise ArgumentError, "gap must not be negative" if gap.negative?
75
+ raise ArgumentError, "an image is wider than max_width" if images.any? { |image| image.width > max_width }
76
+
77
+ bound_height = images.sum(&:height) + (gap * images.length)
78
+ free = [{ x: 0, y: 0, width: max_width, height: bound_height }]
79
+ placements = Array.new(images.length)
80
+ images.each_with_index.sort_by { |image, index| [-image.width * image.height, -[image.width, image.height].max, index] }.each do |image, index|
81
+ packed_width = image.width + gap
82
+ packed_height = image.height + gap
83
+ position = free.select { |rect| packed_width <= rect[:width] && packed_height <= rect[:height] }
84
+ .map do |rect|
85
+ leftover_width = rect[:width] - packed_width
86
+ leftover_height = rect[:height] - packed_height
87
+ score = [
88
+ [leftover_width, leftover_height].min,
89
+ rect[:y],
90
+ rect[:x],
91
+ [leftover_width, leftover_height].max
92
+ ]
93
+ [score, rect[:x], rect[:y]]
94
+ end.min_by(&:first)
95
+ raise ArgumentError, "could not pack images within max_width" unless position
96
+
97
+ _score, x, y = position
98
+ packed = { x:, y:, width: packed_width, height: packed_height }
99
+ placements[index] = { index:, x:, y:, width: image.width, height: image.height }
100
+ free = split_free_rectangles(free, packed)
101
+ end
102
+ used_width = placements.map { |item| item[:x] + item[:width] }.max || 0
103
+ height = placements.map { |item| item[:y] + item[:height] }.max || 0
104
+ output = Tessel::Image.new(used_width, height, fill: background)
105
+ images.zip(placements).each { |image, item| output.blit(image, item[:x], item[:y]) }
106
+ [output, placements]
107
+ end
108
+
109
+ def diff(expected, actual, threshold: 0, color: "#ff00ff")
110
+ raise TypeError, "expected and actual must be Tessel::Image values" unless expected.is_a?(Tessel::Image) && actual.is_a?(Tessel::Image)
111
+ raise ArgumentError, "images must have the same dimensions" unless expected.width == actual.width && expected.height == actual.height
112
+
113
+ threshold = Float(threshold)
114
+ raise ArgumentError, "threshold must be between 0 and 255" unless threshold.finite? && threshold.between?(0, 255)
115
+
116
+ marker = Tessel::Color.pack(color).bytes
117
+ output = Tessel::Image.new(expected.width, expected.height)
118
+ different = 0
119
+ expected.height.times do |y|
120
+ expected.width.times do |x|
121
+ left = expected[x, y]
122
+ right = actual[x, y]
123
+ if left.zip(right).map { |a, b| (a - b).abs }.max > threshold
124
+ output[x, y] = marker
125
+ different += 1
126
+ end
127
+ end
128
+ end
129
+ { image: output, pixels: different, total: expected.width * expected.height,
130
+ rate: expected.width.zero? || expected.height.zero? ? 0.0 : different.to_f / (expected.width * expected.height) }
131
+ end
132
+
133
+ def animate(frames, path, fps: nil, delay: nil, loop: true, colors: 256, palette: :global, dither: :floyd_steinberg)
134
+ require_flipbook
135
+ Flipbook.write(path, validate_images(frames), fps:, delay:, loop:, colors:, palette:, dither:)
136
+ end
137
+
138
+ def open_gif(path, frame: :all, max_pixels: 16_384 * 16_384, max_frames: 10_000, max_total_pixels: 100_000_000)
139
+ require_flipbook
140
+ frames = Flipbook.read(path, max_pixels:, max_frames:, max_total_pixels:)
141
+ return frames if frame == :all
142
+
143
+ frames.fetch(frame == :first ? 0 : Integer(frame))
144
+ end
145
+
146
+ def validate_images(images)
147
+ images = Array(images)
148
+ raise ArgumentError, "at least one image is required" if images.empty?
149
+ raise TypeError, "images must contain only Tessel::Image values" unless images.all?(Tessel::Image)
150
+ raise ArgumentError, "images must have positive dimensions" unless images.all? { |image| image.width.positive? && image.height.positive? }
151
+
152
+ images
153
+ end
154
+ private_class_method :validate_images
155
+
156
+ def split_free_rectangles(free, placed)
157
+ split = free.flat_map do |rect|
158
+ overlaps = placed[:x] < rect[:x] + rect[:width] && placed[:x] + placed[:width] > rect[:x] &&
159
+ placed[:y] < rect[:y] + rect[:height] && placed[:y] + placed[:height] > rect[:y]
160
+ next [rect] unless overlaps
161
+
162
+ pieces = []
163
+ pieces << { x: rect[:x], y: rect[:y], width: rect[:width], height: placed[:y] - rect[:y] } if placed[:y] > rect[:y]
164
+ pieces << { x: rect[:x], y: placed[:y] + placed[:height], width: rect[:width], height: rect[:y] + rect[:height] - placed[:y] - placed[:height] } if placed[:y] + placed[:height] < rect[:y] + rect[:height]
165
+ pieces << { x: rect[:x], y: rect[:y], width: placed[:x] - rect[:x], height: rect[:height] } if placed[:x] > rect[:x]
166
+ pieces << { x: placed[:x] + placed[:width], y: rect[:y], width: rect[:x] + rect[:width] - placed[:x] - placed[:width], height: rect[:height] } if placed[:x] + placed[:width] < rect[:x] + rect[:width]
167
+ pieces.select { |piece| piece[:width].positive? && piece[:height].positive? }
168
+ end
169
+ # ponytail: free-rectangle pruning is O(n²); replace it if large atlases make it slow.
170
+ split.reject.with_index do |rect, index|
171
+ split.each_with_index.any? do |other, other_index|
172
+ next false if index == other_index
173
+
174
+ contained = other[:x] <= rect[:x] && other[:y] <= rect[:y] &&
175
+ other[:x] + other[:width] >= rect[:x] + rect[:width] &&
176
+ other[:y] + other[:height] >= rect[:y] + rect[:height]
177
+ contained && (other != rect || other_index < index)
178
+ end
179
+ end
180
+ end
181
+ private_class_method :split_free_rectangles
182
+
183
+ def require_flipbook
184
+ require "flipbook"
185
+ version = Gem::Version.new(Flipbook::VERSION)
186
+ raise Error, "GIF/APNG support requires flipbook >= 0.4.0 (found #{version})" if version < Gem::Version.new("0.4.0")
187
+ rescue LoadError => e
188
+ raise Error, "GIF/APNG support requires flipbook >= 0.4.0 (gem install flipbook)", cause: e
189
+ end
190
+ private_class_method :require_flipbook
191
+ end
192
+ end