flipbook 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -4
- data/README.md +38 -15
- data/docs/assets/gradient.gif +0 -0
- data/docs/assets/spinning-square.gif +0 -0
- data/docs/assets/transparent.gif +0 -0
- data/docs/assets/transparent.png +0 -0
- data/docs/index.html +32 -0
- data/docs/verification.md +6 -4
- data/examples/generate.rb +49 -0
- data/lib/flipbook/apng/writer.rb +99 -56
- data/lib/flipbook/diff.rb +28 -17
- data/lib/flipbook/gif/lzw.rb +0 -66
- data/lib/flipbook/gif/reader.rb +205 -126
- data/lib/flipbook/gif/writer.rb +50 -52
- data/lib/flipbook/timing.rb +2 -13
- data/lib/flipbook/version.rb +1 -1
- data/lib/flipbook.rb +18 -20
- data/sig/flipbook.rbs +30 -4
- data/sig/tessel.rbs +4 -0
- metadata +11 -3
data/lib/flipbook/gif/reader.rb
CHANGED
|
@@ -3,158 +3,148 @@
|
|
|
3
3
|
module Flipbook
|
|
4
4
|
module GIF
|
|
5
5
|
class Reader
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def self.read(path)
|
|
9
|
-
new(File.binread(path)).frames
|
|
6
|
+
def self.read(path, max_pixels: 16_384 * 16_384, max_frames: 10_000, max_total_pixels: 100_000_000)
|
|
7
|
+
new(File.binread(path), max_pixels:, max_frames:, max_total_pixels:).read
|
|
10
8
|
end
|
|
11
9
|
|
|
12
|
-
def initialize(
|
|
13
|
-
@
|
|
14
|
-
@
|
|
15
|
-
@max_pixels = max_pixels
|
|
16
|
-
@max_frames = max_frames
|
|
17
|
-
@max_total_pixels = max_total_pixels
|
|
18
|
-
@
|
|
19
|
-
@frames = []
|
|
20
|
-
@delays = []
|
|
21
|
-
decode
|
|
10
|
+
def initialize(bytes, max_pixels: 16_384 * 16_384, max_frames: 10_000, max_total_pixels: 100_000_000)
|
|
11
|
+
@bytes = String(bytes).b
|
|
12
|
+
@offset = 0
|
|
13
|
+
@max_pixels = Integer(max_pixels)
|
|
14
|
+
@max_frames = Integer(max_frames)
|
|
15
|
+
@max_total_pixels = Integer(max_total_pixels)
|
|
16
|
+
raise ArgumentError, "limits must be positive" unless @max_pixels.positive? && @max_frames.positive? && @max_total_pixels.positive?
|
|
22
17
|
end
|
|
23
18
|
|
|
24
|
-
def
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def delays
|
|
29
|
-
@delays.dup.freeze
|
|
30
|
-
end
|
|
19
|
+
def read
|
|
20
|
+
signature = take(6)
|
|
21
|
+
raise Error, "invalid GIF signature" unless %w[GIF87a GIF89a].include?(signature)
|
|
31
22
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
@frames.each(&block)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
private
|
|
23
|
+
@width, @height, packed, background_index, = take(7).unpack("v2C3")
|
|
24
|
+
raise Error, "invalid GIF dimensions" if @width.zero? || @height.zero? || @width * @height > @max_pixels
|
|
39
25
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
@
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
background = global && global[@background_index]
|
|
49
|
-
canvas = Tessel::Image.new(@width, @height)
|
|
50
|
-
@current_control = { disposal: 1, delay: 0, transparent: nil }
|
|
51
|
-
previous = nil
|
|
52
|
-
previous_disposal = 0
|
|
53
|
-
restore = nil
|
|
54
|
-
control = { disposal: 1, delay: 0, transparent: nil }
|
|
26
|
+
@global_palette = read_palette(1 << ((packed & 7) + 1)) if (packed & 0x80).positive?
|
|
27
|
+
@background_index = background_index
|
|
28
|
+
@background = palette_color(@global_palette, background_index)
|
|
29
|
+
@frames = []
|
|
30
|
+
@canvas = Tessel::Image.new(@width, @height, fill: @background)
|
|
31
|
+
@control = default_control
|
|
32
|
+
@previous_disposal = nil
|
|
33
|
+
@finished = false
|
|
55
34
|
|
|
56
|
-
until @
|
|
35
|
+
until @finished
|
|
57
36
|
marker = byte
|
|
58
37
|
case marker
|
|
59
|
-
when
|
|
60
|
-
when
|
|
61
|
-
when
|
|
62
|
-
|
|
63
|
-
@current_control = control
|
|
64
|
-
frame = read_frame(global)
|
|
65
|
-
if previous
|
|
66
|
-
if previous_disposal == 2
|
|
67
|
-
color = control_background(global, background, previous[:control][:transparent])
|
|
68
|
-
canvas.fill_rect(previous[:left], previous[:top], previous[:width], previous[:height], color)
|
|
69
|
-
elsif previous_disposal == 3 && restore
|
|
70
|
-
canvas = restore.dup
|
|
71
|
-
end
|
|
72
|
-
elsif background && frame[:control][:transparent] != @background_index
|
|
73
|
-
canvas.clear([*background, 255])
|
|
74
|
-
end
|
|
75
|
-
restore = canvas.dup if frame[:control][:disposal] == 3
|
|
76
|
-
composite(canvas, frame)
|
|
77
|
-
@frames << canvas.dup
|
|
78
|
-
@delays << Rational(frame[:control][:delay], 100)
|
|
79
|
-
previous = frame
|
|
80
|
-
previous_disposal = frame[:control][:disposal]
|
|
81
|
-
control = { disposal: 1, delay: 0, transparent: nil }
|
|
82
|
-
@current_control = control
|
|
83
|
-
else
|
|
84
|
-
raise Error, "invalid GIF block marker"
|
|
38
|
+
when 0x21 then read_extension
|
|
39
|
+
when 0x2c then read_image
|
|
40
|
+
when 0x3b then @finished = true
|
|
41
|
+
else raise Error, "invalid GIF block marker 0x#{marker.to_s(16)}"
|
|
85
42
|
end
|
|
86
43
|
end
|
|
87
44
|
raise Error, "GIF contains no frames" if @frames.empty?
|
|
45
|
+
|
|
46
|
+
@frames
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def default_control
|
|
52
|
+
{ delay: 0, disposal: 0, transparent: nil }
|
|
88
53
|
end
|
|
89
54
|
|
|
90
|
-
def read_extension
|
|
55
|
+
def read_extension
|
|
91
56
|
label = byte
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
raise Error, "invalid GIF control
|
|
96
|
-
|
|
57
|
+
case label
|
|
58
|
+
when 0xf9
|
|
59
|
+
size = byte
|
|
60
|
+
raise Error, "invalid GIF graphic control extension" unless size == 4
|
|
61
|
+
|
|
62
|
+
packed, delay, transparent_index = take(4).unpack("C v C")
|
|
63
|
+
raise Error, "invalid GIF graphic control terminator" unless byte.zero?
|
|
64
|
+
disposal = (packed >> 2) & 7
|
|
65
|
+
raise Error, "unsupported GIF disposal method" if disposal > 3
|
|
66
|
+
|
|
67
|
+
@control = { delay: delay, disposal:, transparent: (packed & 1).positive? ? transparent_index : nil }
|
|
68
|
+
when 0x01
|
|
69
|
+
skip_sub_blocks # fixed plain-text header and text data are both sub-blocks
|
|
97
70
|
else
|
|
98
71
|
skip_sub_blocks
|
|
99
72
|
end
|
|
100
73
|
end
|
|
101
74
|
|
|
102
|
-
def
|
|
75
|
+
def read_image
|
|
103
76
|
left, top, width, height, packed = take(9).unpack("v4C")
|
|
104
|
-
raise Error, "invalid GIF
|
|
105
|
-
raise Error, "GIF
|
|
106
|
-
raise Error, "GIF exceeds total decoded pixel limit" if @decoded_pixels + width * height > @max_total_pixels
|
|
107
|
-
raise Error, "GIF frame lies outside the logical screen" if left + width > @width || top + height > @height
|
|
77
|
+
raise Error, "invalid GIF image dimensions" if width.zero? || height.zero?
|
|
78
|
+
raise Error, "GIF image rectangle is outside the canvas" if left + width > @width || top + height > @height
|
|
108
79
|
|
|
109
|
-
|
|
110
|
-
palette = local || global
|
|
80
|
+
palette = (packed & 0x80).positive? ? read_palette(1 << ((packed & 7) + 1)) : @global_palette
|
|
111
81
|
raise Error, "GIF frame has no color table" unless palette
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
82
|
+
raise Error, "GIF transparent palette index is out of range" if @control[:transparent] && @control[:transparent] >= palette.length
|
|
83
|
+
minimum_code_size = byte
|
|
84
|
+
raise Error, "invalid GIF LZW code size" unless minimum_code_size.between?(2, 8)
|
|
85
|
+
|
|
86
|
+
indices = LZW.decode(read_sub_blocks, minimum_code_size, width * height)
|
|
87
|
+
indices = deinterlace(indices, width, height) if (packed & 0x40).positive?
|
|
88
|
+
draw_frame(indices, palette, left, top, width, height)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def draw_frame(indices, palette, left, top, width, height)
|
|
92
|
+
disposal = @control[:disposal]
|
|
93
|
+
if @previous_disposal
|
|
94
|
+
previous = @previous_disposal
|
|
95
|
+
case previous[:method]
|
|
96
|
+
when 2
|
|
97
|
+
@canvas.fill_rect(*previous[:rect], previous[:background])
|
|
98
|
+
when 3
|
|
99
|
+
@canvas = previous[:restore]
|
|
129
100
|
end
|
|
130
101
|
end
|
|
131
|
-
end
|
|
132
102
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
103
|
+
raise Error, "GIF frame limit exceeded" if @frames.length >= @max_frames
|
|
104
|
+
raise Error, "GIF total pixel limit exceeded" if (@frames.length + 1) * @width * @height > @max_total_pixels
|
|
105
|
+
restore = @canvas.dup if disposal == 3
|
|
106
|
+
transparent = @control[:transparent]
|
|
107
|
+
transparent_background = transparent && (!@global_palette || transparent == @background_index)
|
|
108
|
+
@canvas.clear([0, 0, 0, 0]) if @frames.empty? && transparent_background
|
|
109
|
+
indices.each_with_index do |palette_index, index|
|
|
110
|
+
next if palette_index == transparent
|
|
111
|
+
|
|
112
|
+
color = palette[palette_index]
|
|
113
|
+
raise Error, "GIF palette index is out of range" unless color
|
|
136
114
|
|
|
137
|
-
|
|
115
|
+
x = left + index % width
|
|
116
|
+
y = top + index / width
|
|
117
|
+
@canvas[x, y] = [*color, 255]
|
|
118
|
+
end
|
|
119
|
+
@frames << @canvas.dup
|
|
120
|
+
background = if transparent_background
|
|
121
|
+
[0, 0, 0, 0]
|
|
122
|
+
else
|
|
123
|
+
[*@background, 255]
|
|
124
|
+
end
|
|
125
|
+
@previous_disposal = { method: disposal, rect: [left, top, width, height], background:, restore: }
|
|
126
|
+
@control = default_control
|
|
138
127
|
end
|
|
139
128
|
|
|
140
129
|
def deinterlace(indices, width, height)
|
|
141
|
-
rows =
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
130
|
+
rows = Array.new(height)
|
|
131
|
+
offset = 0
|
|
132
|
+
[[0, 8], [4, 8], [2, 4], [1, 2]].each do |start, step|
|
|
133
|
+
(start...height).step(step) do |y|
|
|
134
|
+
rows[y] = indices.slice(offset, width)
|
|
135
|
+
offset += width
|
|
136
|
+
end
|
|
148
137
|
end
|
|
149
|
-
|
|
138
|
+
rows.flatten
|
|
150
139
|
end
|
|
151
140
|
|
|
152
141
|
def read_palette(count)
|
|
153
|
-
take(count * 3).
|
|
142
|
+
take(count * 3).unpack("C*").each_slice(3).map(&:freeze)
|
|
154
143
|
end
|
|
155
144
|
|
|
156
|
-
def
|
|
157
|
-
|
|
145
|
+
def palette_color(palette, index)
|
|
146
|
+
color = palette&.[](index)
|
|
147
|
+
color || [0, 0, 0]
|
|
158
148
|
end
|
|
159
149
|
|
|
160
150
|
def read_sub_blocks
|
|
@@ -162,28 +152,117 @@ module Flipbook
|
|
|
162
152
|
loop do
|
|
163
153
|
length = byte
|
|
164
154
|
break if length.zero?
|
|
165
|
-
raise Error, "GIF sub-block exceeds input" if @cursor + length > @data.bytesize
|
|
166
155
|
|
|
167
156
|
output << take(length)
|
|
168
157
|
end
|
|
169
158
|
output
|
|
170
159
|
end
|
|
171
160
|
|
|
172
|
-
def
|
|
173
|
-
|
|
161
|
+
def skip_sub_blocks
|
|
162
|
+
loop do
|
|
163
|
+
length = byte
|
|
164
|
+
break if length.zero?
|
|
174
165
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
value
|
|
166
|
+
take(length)
|
|
167
|
+
end
|
|
178
168
|
end
|
|
179
169
|
|
|
180
170
|
def take(length)
|
|
181
|
-
raise Error, "truncated GIF" if length.negative? || @
|
|
171
|
+
raise Error, "truncated GIF data" if length.negative? || @offset + length > @bytes.bytesize
|
|
182
172
|
|
|
183
|
-
|
|
184
|
-
@
|
|
185
|
-
|
|
173
|
+
result = @bytes.byteslice(@offset, length)
|
|
174
|
+
@offset += length
|
|
175
|
+
result
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def byte
|
|
179
|
+
take(1).getbyte(0)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
module LZW
|
|
184
|
+
module_function
|
|
185
|
+
|
|
186
|
+
def decode(bytes, minimum_code_size, expected_size)
|
|
187
|
+
clear = 1 << minimum_code_size
|
|
188
|
+
ending = clear + 1
|
|
189
|
+
prefix = Array.new(4096)
|
|
190
|
+
suffix = Array.new(4096)
|
|
191
|
+
clear.times { |index| suffix[index] = index }
|
|
192
|
+
next_code = ending + 1
|
|
193
|
+
width = minimum_code_size + 1
|
|
194
|
+
bit_offset = 0
|
|
195
|
+
previous = nil
|
|
196
|
+
first = nil
|
|
197
|
+
output = String.new(capacity: expected_size, encoding: Encoding::BINARY)
|
|
198
|
+
ended = false
|
|
199
|
+
|
|
200
|
+
loop do
|
|
201
|
+
code = read_code(bytes, bit_offset, width)
|
|
202
|
+
break unless code
|
|
203
|
+
|
|
204
|
+
bit_offset += width
|
|
205
|
+
if code == clear
|
|
206
|
+
clear.times { |index| suffix[index] = index }
|
|
207
|
+
next_code = ending + 1
|
|
208
|
+
width = minimum_code_size + 1
|
|
209
|
+
previous = nil
|
|
210
|
+
next
|
|
211
|
+
end
|
|
212
|
+
if code == ending
|
|
213
|
+
ended = true
|
|
214
|
+
break
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
if code < next_code && suffix[code]
|
|
218
|
+
entry, first = expand(code, clear, prefix, suffix)
|
|
219
|
+
elsif code == next_code && previous
|
|
220
|
+
entry, first = expand(previous, clear, prefix, suffix)
|
|
221
|
+
entry << first
|
|
222
|
+
else
|
|
223
|
+
raise Error, "invalid GIF LZW code"
|
|
224
|
+
end
|
|
225
|
+
output << entry.pack("C*")
|
|
226
|
+
raise Error, "GIF frame data exceeds its dimensions" if output.bytesize > expected_size
|
|
227
|
+
|
|
228
|
+
if previous && next_code < 4096
|
|
229
|
+
prefix[next_code] = previous
|
|
230
|
+
suffix[next_code] = first
|
|
231
|
+
next_code += 1
|
|
232
|
+
width += 1 if next_code == (1 << width) && width < 12
|
|
233
|
+
end
|
|
234
|
+
previous = code
|
|
235
|
+
end
|
|
236
|
+
raise Error, "truncated GIF LZW data" unless ended && output.bytesize == expected_size
|
|
237
|
+
|
|
238
|
+
output.bytes
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def expand(code, clear, prefix, suffix)
|
|
242
|
+
stack = []
|
|
243
|
+
current = code
|
|
244
|
+
while current >= clear
|
|
245
|
+
raise Error, "invalid GIF LZW dictionary" unless current < 4096 && suffix[current] && prefix[current]
|
|
246
|
+
|
|
247
|
+
stack << suffix[current]
|
|
248
|
+
current = prefix[current]
|
|
249
|
+
end
|
|
250
|
+
stack << current
|
|
251
|
+
first = current
|
|
252
|
+
[stack.reverse, first]
|
|
253
|
+
end
|
|
254
|
+
private_class_method :expand
|
|
255
|
+
|
|
256
|
+
def read_code(bytes, bit_offset, width)
|
|
257
|
+
return nil if bit_offset + width > bytes.bytesize * 8
|
|
258
|
+
|
|
259
|
+
code = 0
|
|
260
|
+
width.times do |bit|
|
|
261
|
+
code |= ((bytes.getbyte((bit_offset + bit) / 8) >> ((bit_offset + bit) % 8)) & 1) << bit
|
|
262
|
+
end
|
|
263
|
+
code
|
|
186
264
|
end
|
|
265
|
+
private_class_method :read_code
|
|
187
266
|
end
|
|
188
267
|
end
|
|
189
268
|
end
|
data/lib/flipbook/gif/writer.rb
CHANGED
|
@@ -20,38 +20,35 @@ module Flipbook
|
|
|
20
20
|
raise
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
def initialize(io, width:, height:, loop: true, palette: :per_frame, colors: 256, dither: :floyd_steinberg,
|
|
23
|
+
def initialize(io, width:, height:, loop: true, palette: :per_frame, colors: 256, dither: :floyd_steinberg, transparent_index: nil, optimize: true)
|
|
24
24
|
@io = io
|
|
25
25
|
@width = Integer(width)
|
|
26
26
|
@height = Integer(height)
|
|
27
27
|
raise ArgumentError, "GIF dimensions must be between 1 and 65535" unless @width.between?(1, 65_535) && @height.between?(1, 65_535)
|
|
28
28
|
raise ArgumentError, "colors must be between 2 and 256" unless colors.is_a?(Integer) && colors.between?(2, 256)
|
|
29
29
|
raise ArgumentError, "unknown dither: #{dither}" unless %i[none ordered floyd_steinberg].include?(dither)
|
|
30
|
+
raise TypeError, "optimize must be true or false" unless [true, false].include?(optimize)
|
|
30
31
|
|
|
31
32
|
@loop = loop_value(loop)
|
|
32
33
|
@colors = colors
|
|
33
34
|
@dither = dither
|
|
34
|
-
@
|
|
35
|
-
@
|
|
36
|
-
@previous = nil
|
|
35
|
+
@optimize = optimize
|
|
36
|
+
@previous_image = nil
|
|
37
37
|
@elapsed = Rational(0)
|
|
38
38
|
@rounded_delay = 0
|
|
39
39
|
@closed = false
|
|
40
40
|
@warned_short_delay = false
|
|
41
41
|
@frame_count = 0
|
|
42
|
+
@pending_frame = nil
|
|
43
|
+
@canvas_cleared = false
|
|
42
44
|
@palette_mode = palette
|
|
43
45
|
if palette == :per_frame
|
|
44
|
-
raise ArgumentError, "clear_background requires a global palette" if clear_background
|
|
45
46
|
raise ArgumentError, "transparent_index is only valid with a global palette" unless transparent_index.nil?
|
|
46
47
|
@global_palette = nil
|
|
48
|
+
@global_transparent_index = 0
|
|
47
49
|
else
|
|
48
50
|
raise TypeError, "palette must be :per_frame or an RGB array" unless palette.is_a?(Array)
|
|
49
51
|
@global_palette = validate_palette(palette)
|
|
50
|
-
if (@optimize || clear_background) && transparent_index.nil?
|
|
51
|
-
raise ArgumentError, "global palettes need room for a transparent color when optimize is enabled" if @global_palette.length == 256
|
|
52
|
-
@global_palette = @global_palette + [[0, 0, 0]]
|
|
53
|
-
transparent_index = @global_palette.length - 1
|
|
54
|
-
end
|
|
55
52
|
if transparent_index
|
|
56
53
|
raise ArgumentError, "transparent_index must be the last palette entry" unless transparent_index == @global_palette.length - 1
|
|
57
54
|
raise ArgumentError, "transparent_index is out of range" unless transparent_index.between?(0, @global_palette.length - 1)
|
|
@@ -73,41 +70,26 @@ module Flipbook
|
|
|
73
70
|
@warned_short_delay = true
|
|
74
71
|
end
|
|
75
72
|
transparent_pixels = image.bytes.bytes.each_slice(4).any? { |_, _, _, alpha| alpha.zero? }
|
|
76
|
-
if @
|
|
77
|
-
|
|
73
|
+
if @pending_frame
|
|
74
|
+
disposal = (@pending_frame[:transparent] || transparent_pixels) ? 2 : 1
|
|
75
|
+
write_pending(disposal)
|
|
76
|
+
@canvas_cleared = disposal == 2
|
|
78
77
|
end
|
|
78
|
+
|
|
79
79
|
elapsed = @elapsed + seconds
|
|
80
80
|
target_delay = (elapsed * 100).floor
|
|
81
81
|
frame_delay = target_delay - @rounded_delay
|
|
82
82
|
raise ArgumentError, "GIF frame delays cannot exceed 655.35 seconds" if frame_delay > 65_535
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
image.bytes.bytes.each_slice(4).with_index do |(_, _, _, alpha), index|
|
|
88
|
-
indices.setbyte(index, transparent_index) if alpha.zero?
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
left, top, width, height = @optimize ? Diff.bounds(@previous, image) : [0, 0, @width, @height]
|
|
93
|
-
if @previous && @optimize && transparent_index
|
|
94
|
-
image_bytes = image.bytes
|
|
95
|
-
old_bytes = @previous.bytes
|
|
96
|
-
(top...(top + height)).each do |y|
|
|
97
|
-
(left...(left + width)).each do |x|
|
|
98
|
-
offset = (y * @width + x) * 4
|
|
99
|
-
indices.setbyte(y * @width + x, transparent_index) if image_bytes.byteslice(offset, 4) == old_bytes.byteslice(offset, 4)
|
|
100
|
-
end
|
|
101
|
-
end
|
|
83
|
+
rectangle = if @optimize && @previous_image && !transparent_pixels && !@canvas_cleared
|
|
84
|
+
Diff.bounds(@previous_image, image) || [0, 0, 1, 1]
|
|
85
|
+
else
|
|
86
|
+
[0, 0, @width, @height]
|
|
102
87
|
end
|
|
103
|
-
|
|
104
|
-
write_control(frame_delay, transparent_index)
|
|
105
|
-
write_image(left, top, width, height, frame_indices, palette)
|
|
88
|
+
@pending_frame = { image: image.dup, delay: frame_delay, rectangle:, transparent: transparent_pixels }
|
|
106
89
|
@elapsed = elapsed
|
|
107
90
|
@rounded_delay = target_delay
|
|
108
91
|
@frame_count += 1
|
|
109
|
-
@
|
|
110
|
-
@previous = image.dup
|
|
92
|
+
@previous_image = image.dup
|
|
111
93
|
self
|
|
112
94
|
end
|
|
113
95
|
|
|
@@ -115,6 +97,7 @@ module Flipbook
|
|
|
115
97
|
return if @closed
|
|
116
98
|
raise Error, "GIF contains no frames" if @frame_count.zero?
|
|
117
99
|
|
|
100
|
+
write_pending(@pending_frame[:transparent] ? 2 : 1)
|
|
118
101
|
@io << ";".b
|
|
119
102
|
@closed = true
|
|
120
103
|
@io.close if @owns_io && !@io.closed?
|
|
@@ -148,30 +131,52 @@ module Flipbook
|
|
|
148
131
|
return [@global_palette, opaque, @global_transparent_index]
|
|
149
132
|
end
|
|
150
133
|
|
|
151
|
-
|
|
152
|
-
opaque_count = reserve ? [@colors - 1, 1].max : @colors
|
|
134
|
+
opaque_count = [@colors - 1, 1].max
|
|
153
135
|
opaque = Tessel::Quantize.palette_for(image, colors: [opaque_count, 2].max).first(opaque_count)
|
|
154
|
-
|
|
155
|
-
|
|
136
|
+
[[[0, 0, 0]] + opaque, opaque, 0]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def write_pending(disposal)
|
|
140
|
+
pending = @pending_frame
|
|
141
|
+
image = pending[:image]
|
|
142
|
+
left, top, width, height = pending[:rectangle]
|
|
143
|
+
frame = if left.zero? && top.zero? && width == @width && height == @height
|
|
144
|
+
image
|
|
145
|
+
else
|
|
146
|
+
image.crop(left, top, width, height)
|
|
147
|
+
end
|
|
148
|
+
palette, opaque_palette, transparent_index = frame_palette(image, pending[:transparent])
|
|
149
|
+
indices, = Tessel::Quantize.quantize(frame, palette: opaque_palette, dither: @dither)
|
|
150
|
+
indices.each_byte.with_index { |index, offset| indices.setbyte(offset, index + 1) } if @palette_mode == :per_frame
|
|
151
|
+
if pending[:transparent]
|
|
152
|
+
raise ArgumentError, "palette has no transparent entry" unless transparent_index
|
|
153
|
+
frame.bytes.bytes.each_slice(4).with_index do |(_, _, _, alpha), index|
|
|
154
|
+
indices.setbyte(index, transparent_index) if alpha.zero?
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
write_control(pending[:delay], transparent_index, disposal:)
|
|
159
|
+
write_image(left, top, width, height, indices, palette)
|
|
160
|
+
@pending_frame = nil
|
|
156
161
|
end
|
|
157
162
|
|
|
158
163
|
def write_header
|
|
159
164
|
@io << "GIF89a".b << [@width, @height].pack("v2")
|
|
160
165
|
if @global_palette
|
|
161
166
|
bits, size = table_size(@global_palette.length)
|
|
162
|
-
|
|
163
|
-
@io << [0x80 | 0x70 | (bits - 1), background, 0].pack("C3")
|
|
167
|
+
@io << [0x80 | 0x70 | (bits - 1), @global_transparent_index || 0, 0].pack("C3")
|
|
164
168
|
write_palette(@global_palette, size)
|
|
165
169
|
else
|
|
166
|
-
@io <<
|
|
170
|
+
@io << [0x80 | 0x70, 0, 0].pack("C3")
|
|
171
|
+
write_palette([[0, 0, 0]], 2)
|
|
167
172
|
end
|
|
168
173
|
if @loop
|
|
169
174
|
@io << "!\xFF\x0BNETSCAPE2.0\x03\x01".b << [@loop].pack("v") << "\0".b
|
|
170
175
|
end
|
|
171
176
|
end
|
|
172
177
|
|
|
173
|
-
def write_control(delay, transparent_index)
|
|
174
|
-
packed =
|
|
178
|
+
def write_control(delay, transparent_index, disposal:)
|
|
179
|
+
packed = disposal << 2
|
|
175
180
|
packed |= 0x01 if transparent_index
|
|
176
181
|
@io << "!\xF9\x04".b << [packed, delay, transparent_index || 0, 0].pack("CvCC")
|
|
177
182
|
end
|
|
@@ -196,13 +201,6 @@ module Flipbook
|
|
|
196
201
|
(size - palette.length).times { @io << "\0\0\0".b }
|
|
197
202
|
end
|
|
198
203
|
|
|
199
|
-
def crop_indices(indices, left, top, width, height)
|
|
200
|
-
output = String.new(capacity: width * height, encoding: Encoding::BINARY)
|
|
201
|
-
height.times do |row|
|
|
202
|
-
output << indices.byteslice((top + row) * @width + left, width)
|
|
203
|
-
end
|
|
204
|
-
output
|
|
205
|
-
end
|
|
206
204
|
end
|
|
207
205
|
end
|
|
208
206
|
end
|
data/lib/flipbook/timing.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Flipbook
|
|
|
4
4
|
module Timing
|
|
5
5
|
module_function
|
|
6
6
|
|
|
7
|
-
def delays(count, fps: nil, delay: nil)
|
|
7
|
+
def delays(count, fps: nil, delay: nil, warn_short: true)
|
|
8
8
|
raise ArgumentError, "frame count must be a positive integer" unless count.is_a?(Integer) && count.positive?
|
|
9
9
|
raise ArgumentError, "provide fps or delay, not both" if !fps.nil? && !delay.nil?
|
|
10
10
|
raise ArgumentError, "fps or delay is required" if fps.nil? && delay.nil?
|
|
@@ -19,7 +19,7 @@ module Flipbook
|
|
|
19
19
|
else
|
|
20
20
|
Array.new(count) { positive_delay(delay) }
|
|
21
21
|
end
|
|
22
|
-
warn("Flipbook: delays below 1/50 second may be clamped by GIF viewers") if values.any? { |value| value < Rational(1, 50) }
|
|
22
|
+
warn("Flipbook: delays below 1/50 second may be clamped by GIF viewers") if warn_short && values.any? { |value| value < Rational(1, 50) }
|
|
23
23
|
values
|
|
24
24
|
end
|
|
25
25
|
|
|
@@ -39,17 +39,6 @@ module Flipbook
|
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
def apng_fraction(delay)
|
|
43
|
-
value = rational(delay)
|
|
44
|
-
raise ArgumentError, "APNG delays cannot exceed 65535 seconds" if value > 65_535
|
|
45
|
-
max_denominator = value > 1 ? (65_535 / value).floor : 65_535
|
|
46
|
-
denominator = [value.denominator, max_denominator, 65_535].min
|
|
47
|
-
denominator = 1 if denominator.zero?
|
|
48
|
-
numerator = [(value * denominator).round, 1].max
|
|
49
|
-
divisor = numerator.gcd(denominator)
|
|
50
|
-
[numerator / divisor, denominator / divisor]
|
|
51
|
-
end
|
|
52
|
-
|
|
53
42
|
def rational(value)
|
|
54
43
|
raise TypeError, "delay and fps must be numeric" unless value.is_a?(Numeric)
|
|
55
44
|
|
data/lib/flipbook/version.rb
CHANGED