flipbook 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 21a1c656c9c85bbfdc6ace9cc7c09f3cbc8546c4f716d899d9bc72a1ec42e30b
4
- data.tar.gz: fd9bda09b22c184e7f95933a9a795edfe4739e965eb1d0fc254d7eb35705a14b
3
+ metadata.gz: 9873e25f97a747f0f45e26e9eb9c503228d6b2daa975a8976f64480c1b3e0ba0
4
+ data.tar.gz: 90bfb6579e71d4fdea9035fe86561fcde8a4911acb56f9ce8414136d0004abd6
5
5
  SHA512:
6
- metadata.gz: fdd1e8f6929f091b6dad1e8de5fc892ae6e17704d53ac564911962a2671c70e3f2864d751860ea66be5908b065a2276256b2a38e1ef6b4cb35db15fe68a6773c
7
- data.tar.gz: cd3b12ca02ee239a641db540dede16bcc59647087e083a1cf78205d3b01ec2364c961d412dd9426ced032a839366d98c703f8d1b890b9a1e9d93338d02c5a91c
6
+ metadata.gz: eb493019ec22fce32d0f4741bd1db36dbc8403b1528b267f8abb94b99865de2ceed88f50e24efdf145f760de33ea1526ce5206d258b6739043135a87da2ead85
7
+ data.tar.gz: e9a8476651fbfbe0cf0ff3a8092b0a7c0d3b9ee80a50c95a9a3ae8e0394c8275c84f5be424ce7ed65bcfa722a1002546e98d2f57ae630dd5cd599608caedb937
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0] - 2026-09-25
4
+
5
+ - Read composited GIF frames with configurable image, frame, and total-pixel limits.
6
+ - Improve APNG writing and changed-region encoding.
7
+
8
+ ## [0.3.0] - 2026-09-25
9
+
10
+ - Add GIF reading with LZW decoding, interlace handling, frame composition, and disposal methods.
11
+ - Clear transparent GIF frames correctly and retain delta cropping for opaque animations.
12
+
3
13
  ## [0.2.0] - 2026-09-25
4
14
 
5
15
  - Add APNG writing with rational frame delays, alpha, and changed-region cropping.
data/README.md CHANGED
@@ -63,11 +63,20 @@ Flipbook.write("animation.png", frames, fps: 30, loop: true)
63
63
 
64
64
  `optimize: false` writes full-size frames. The default crops each changed frame to its smallest bounding rectangle. GIF cropping is used for opaque frames; APNG cropping preserves alpha changes.
65
65
 
66
+ Read GIFs as full-canvas `Tessel::Image` frames. The reader applies frame offsets, transparency, interlacing, and disposal before returning:
67
+
68
+ ~~~ruby
69
+ frames = Flipbook.read("animation.gif")
70
+ ~~~
71
+
72
+ `max_pixels:`, `max_frames:`, and `max_total_pixels:` can lower the decoder's resource limits for untrusted files.
73
+
66
74
  ### API contracts
67
75
 
68
76
  - `Flipbook.write(path, frames, fps: or delay:)` requires one or more same-sized `Tessel::Image` objects and a `.gif`, `.png`, or `.apng` output path.
69
77
  - `GIF::Writer#add` raises `TypeError` for non-image frames and `ArgumentError` for invalid dimensions or timing.
70
78
  - `APNG::Writer#add` accepts positive numeric delays in seconds and raises for frames that do not match the canvas dimensions.
79
+ - `Flipbook.read(path)` returns fully composited GIF frames and rejects malformed streams or files over its configured limits.
71
80
  - GIF palette colors are RGB triples of integer channels from 0 to 255. GIF transparency is binary; partial alpha is encoded as an opaque palette color.
72
81
 
73
82
  ## Examples
@@ -78,6 +87,8 @@ Generate a rotating square, a color gradient, and transparent GIF/APNG samples:
78
87
  ruby examples/generate.rb
79
88
  ~~~
80
89
 
90
+ ![A blue square rotating in an animated GIF](docs/assets/spinning-square.gif)
91
+
81
92
  Pass an output directory as the first argument to choose where the files are written.
82
93
 
83
94
  GIF format acknowledgement: The Graphics Interchange Format© is the Copyright property of CompuServe Incorporated. GIF® is a Service Mark property of CompuServe Incorporated.
Binary file
Binary file
Binary file
Binary file
data/docs/index.html ADDED
@@ -0,0 +1,32 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="theme-color" content="#101318">
7
+ <meta name="description" content="Write optimized animated GIF and APNG files from Ruby images with Flipbook.">
8
+ <title>Flipbook — animation from Ruby frames</title>
9
+ <style>
10
+ :root{color-scheme:dark;--bg:#101318;--panel:#181e27;--line:#2b3441;--text:#f1f4f8;--muted:#9ca9b8;--accent:#38bdf8;--mono:ui-monospace,SFMono-Regular,Menlo,monospace;--sans:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
11
+ *{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 75% -12%,#16384c,transparent 45%),var(--bg);color:var(--text);font:16px/1.65 var(--sans)}a{color:inherit;text-decoration:none}a:hover{color:var(--accent)}.wrap{width:min(1080px,calc(100% - 40px));margin:auto}.nav{height:76px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{font-size:20px;font-weight:750;letter-spacing:-.04em}.brand span,.label{color:var(--accent)}.links{display:flex;gap:24px;color:var(--muted);font-size:14px}.hero{padding:84px 0 72px;display:grid;grid-template-columns:1fr .82fr;align-items:center;gap:56px}.label{font:12px var(--mono);text-transform:uppercase;letter-spacing:.14em}h1{font-size:clamp(48px,7vw,78px);line-height:.99;letter-spacing:-.075em;margin:18px 0}.hero p{font-size:18px;color:var(--muted);max-width:600px}.actions{display:flex;gap:12px;flex-wrap:wrap;margin:28px 0 22px}.button{padding:10px 16px;border:1px solid var(--line);border-radius:5px;font-weight:650}.primary{color:#061820;background:var(--accent);border-color:var(--accent)}.install{display:inline-flex;gap:16px;background:#090d12;border:1px solid var(--line);border-radius:6px;padding:12px 15px;font:14px var(--mono)}.install span{color:#6c7886}.visual{padding:20px;border:1px solid var(--line);border-radius:10px;background:linear-gradient(145deg,#1b2632,#11171e);box-shadow:0 24px 80px #0007;transform:rotate(1deg)}.window{height:34px;border-bottom:1px solid var(--line);margin:-2px 0 16px;display:flex;align-items:center;gap:7px}.dot{width:8px;height:8px;border-radius:50%;background:#566170}.filename{margin-left:8px;color:var(--muted);font:11px var(--mono)}.visual img{display:block;width:100%;border-radius:5px;image-rendering:auto}.caption{display:flex;justify-content:space-between;color:var(--muted);font:11px var(--mono);padding:12px 2px 0}.section{border-top:1px solid var(--line);padding:42px 0 66px}.section h2{font-size:28px;letter-spacing:-.04em;margin:8px 0}.section>p{color:var(--muted);max-width:650px}.formats{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:24px}.card{background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden}.card img{display:block;width:100%;height:200px;object-fit:contain;background:#0b1016;padding:18px}.cardbody{padding:16px 18px}.card h3{margin:0;font-size:16px}.card p{color:var(--muted);font-size:14px;margin:5px 0 0}.code{background:#090d12;border:1px solid var(--line);border-radius:7px;padding:18px;overflow:auto;font:14px/1.8 var(--mono);color:#d5eaf5}.foot{border-top:1px solid var(--line);padding:24px 0 36px;display:flex;justify-content:space-between;gap:16px;color:var(--muted);font-size:13px}
12
+ @media(max-width:740px){.hero{grid-template-columns:1fr;gap:32px;padding:66px 0 54px}.visual{max-width:500px}.links{gap:14px}.formats{grid-template-columns:1fr}.foot{flex-direction:column}}
13
+ </style>
14
+ </head>
15
+ <body><div class="wrap">
16
+ <nav class="nav" aria-label="Main navigation"><a class="brand" href="#top">flip<span>book</span></a><div class="links"><a href="#formats">Formats</a><a href="https://github.com/rbgfx/flipbook">GitHub</a><a href="https://rubygems.org/gems/flipbook">RubyGems</a></div></nav>
17
+ <main id="top">
18
+ <section class="hero"><div><div class="label">Animation from Ruby frames</div><h1>Every frame<br>in its place.</h1><p>Write optimized animated GIF and APNG files from Tessel images. Keep GIF palettes steady, preserve full-color alpha in APNG, and crop unchanged frame regions.</p><div class="actions"><a class="button primary" href="https://github.com/rbgfx/flipbook#api">Explore the API ↗</a><a class="button" href="https://github.com/rbgfx/flipbook/tree/main/examples">See examples</a></div><div class="install"><span>$</span> gem install flipbook</div></div><div class="visual"><div class="window"><i class="dot"></i><i class="dot"></i><i class="dot"></i><span class="filename">spinning-square.gif</span></div><img src="assets/spinning-square.gif" alt="A blue square rotating on a dark background"><div class="caption"><span>GIF · 12 FPS</span><span>24 frames</span></div></div></section>
19
+ <section class="section" id="formats"><div class="label">Pick the right format</div><h2>Palette animation or full color</h2><p>Use GIF for compact, broadly supported animations; choose APNG when full RGBA color and partial transparency matter.</p><div class="formats"><article class="card"><img src="assets/gradient.gif" alt="A blue-to-red color gradient"><div class="cardbody"><h3>GIF · up to 256 palette colors</h3><p>Global palettes keep colors consistent. Per-frame palettes are available for streaming.</p></div></article><article class="card"><img src="assets/transparent.png" alt="An animated pink circle with transparent background"><div class="cardbody"><h3>APNG · full RGBA</h3><p>A standard PNG first frame, with full alpha and optimized animated updates.</p></div></article></div></section>
20
+ <section class="section"><div class="label">Quick start</div><h2>Write from Tessel frames</h2><pre class="code"><code>require "flipbook"
21
+
22
+ frames = 60.times.map do |index|
23
+ image = Tessel::Image.new(320, 240, fill: "#101827")
24
+ image.fill_rect(index * 4 % 320, 100, 32, 32, "#38bdf8")
25
+ image
26
+ end
27
+
28
+ Flipbook.write("animation.gif", frames, fps: 30, loop: true)
29
+ Flipbook.write("animation.png", frames, fps: 30, loop: true)</code></pre></section>
30
+ </main><footer class="foot"><span>Flipbook · Animated GIF and APNG for Ruby</span><span><a href="https://github.com/rbgfx/flipbook">Source</a> · MIT License</span></footer>
31
+ </div></body>
32
+ </html>
data/docs/verification.md CHANGED
@@ -1,14 +1,16 @@
1
1
  # File verification
2
2
 
3
- The test suite checks GIF LZW code-width growth and dictionary resets, frame timing, transparent palette entries, cropped frame bounds, APNG sequence numbers, and PNG chunk CRCs.
3
+ The test suite checks GIF LZW code-width growth and dictionary resets, frame composition and disposal, resource limits, frame timing, transparent palette entries, cropped frame bounds, APNG sequence numbers, and PNG chunk CRCs.
4
4
 
5
- For an independent local check, install ImageMagick or gifsicle and inspect sample output:
5
+ For an independent local check, install ImageMagick, FFmpeg, or gifsicle and inspect sample output:
6
6
 
7
7
  ~~~sh
8
8
  identify animation.gif
9
9
  magick animation.gif -coalesce /tmp/flipbook-gif-%02d.png
10
10
  gifsicle --info animation.gif
11
11
  magick identify animation.png
12
+ ffprobe -v error -count_frames -select_streams v:0 \\
13
+ -show_entries stream=codec_name,nb_read_frames animation.png
12
14
  ~~~
13
15
 
14
16
  These tools are optional and are not runtime dependencies.
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flipbook
4
+ module GIF
5
+ class Reader
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
8
+ end
9
+
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?
17
+ end
18
+
19
+ def read
20
+ signature = take(6)
21
+ raise Error, "invalid GIF signature" unless %w[GIF87a GIF89a].include?(signature)
22
+
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
25
+
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
34
+
35
+ until @finished
36
+ marker = byte
37
+ case 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)}"
42
+ end
43
+ end
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 }
53
+ end
54
+
55
+ def read_extension
56
+ label = byte
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
70
+ else
71
+ skip_sub_blocks
72
+ end
73
+ end
74
+
75
+ def read_image
76
+ left, top, width, height, packed = take(9).unpack("v4C")
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
79
+
80
+ palette = (packed & 0x80).positive? ? read_palette(1 << ((packed & 7) + 1)) : @global_palette
81
+ raise Error, "GIF frame has no color table" unless palette
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]
100
+ end
101
+ end
102
+
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
114
+
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
127
+ end
128
+
129
+ def deinterlace(indices, width, height)
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
137
+ end
138
+ rows.flatten
139
+ end
140
+
141
+ def read_palette(count)
142
+ take(count * 3).unpack("C*").each_slice(3).map(&:freeze)
143
+ end
144
+
145
+ def palette_color(palette, index)
146
+ color = palette&.[](index)
147
+ color || [0, 0, 0]
148
+ end
149
+
150
+ def read_sub_blocks
151
+ output = "".b
152
+ loop do
153
+ length = byte
154
+ break if length.zero?
155
+
156
+ output << take(length)
157
+ end
158
+ output
159
+ end
160
+
161
+ def skip_sub_blocks
162
+ loop do
163
+ length = byte
164
+ break if length.zero?
165
+
166
+ take(length)
167
+ end
168
+ end
169
+
170
+ def take(length)
171
+ raise Error, "truncated GIF data" if length.negative? || @offset + length > @bytes.bytesize
172
+
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
264
+ end
265
+ private_class_method :read_code
266
+ end
267
+ end
268
+ end
@@ -39,10 +39,13 @@ module Flipbook
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
46
  raise ArgumentError, "transparent_index is only valid with a global palette" unless transparent_index.nil?
45
47
  @global_palette = nil
48
+ @global_transparent_index = 0
46
49
  else
47
50
  raise TypeError, "palette must be :per_frame or an RGB array" unless palette.is_a?(Array)
48
51
  @global_palette = validate_palette(palette)
@@ -67,28 +70,22 @@ module Flipbook
67
70
  @warned_short_delay = true
68
71
  end
69
72
  transparent_pixels = image.bytes.bytes.each_slice(4).any? { |_, _, _, alpha| alpha.zero? }
73
+ if @pending_frame
74
+ disposal = (@pending_frame[:transparent] || transparent_pixels) ? 2 : 1
75
+ write_pending(disposal)
76
+ @canvas_cleared = disposal == 2
77
+ end
78
+
70
79
  elapsed = @elapsed + seconds
71
80
  target_delay = (elapsed * 100).floor
72
81
  frame_delay = target_delay - @rounded_delay
73
82
  raise ArgumentError, "GIF frame delays cannot exceed 655.35 seconds" if frame_delay > 65_535
74
- palette, opaque_palette, transparent_index = frame_palette(image, transparent_pixels)
75
- rectangle = if @optimize && @previous_image && !transparent_pixels
83
+ rectangle = if @optimize && @previous_image && !transparent_pixels && !@canvas_cleared
76
84
  Diff.bounds(@previous_image, image) || [0, 0, 1, 1]
77
85
  else
78
86
  [0, 0, @width, @height]
79
87
  end
80
- left, top, width, height = rectangle
81
- frame = rectangle == [0, 0, @width, @height] ? image : image.crop(left, top, width, height)
82
- indices, = Tessel::Quantize.quantize(frame, palette: opaque_palette, dither: @dither)
83
- if transparent_pixels
84
- raise ArgumentError, "palette has no transparent entry" unless transparent_index
85
- frame.bytes.bytes.each_slice(4).with_index do |(_, _, _, alpha), index|
86
- indices.setbyte(index, transparent_index) if alpha.zero?
87
- end
88
- end
89
-
90
- write_control(frame_delay, transparent_index)
91
- write_image(left, top, width, height, indices, palette)
88
+ @pending_frame = { image: image.dup, delay: frame_delay, rectangle:, transparent: transparent_pixels }
92
89
  @elapsed = elapsed
93
90
  @rounded_delay = target_delay
94
91
  @frame_count += 1
@@ -100,6 +97,7 @@ module Flipbook
100
97
  return if @closed
101
98
  raise Error, "GIF contains no frames" if @frame_count.zero?
102
99
 
100
+ write_pending(@pending_frame[:transparent] ? 2 : 1)
103
101
  @io << ";".b
104
102
  @closed = true
105
103
  @io.close if @owns_io && !@io.closed?
@@ -133,11 +131,33 @@ module Flipbook
133
131
  return [@global_palette, opaque, @global_transparent_index]
134
132
  end
135
133
 
136
- reserve = transparent_pixels
137
- opaque_count = reserve ? [@colors - 1, 1].max : @colors
134
+ opaque_count = [@colors - 1, 1].max
138
135
  opaque = Tessel::Quantize.palette_for(image, colors: [opaque_count, 2].max).first(opaque_count)
139
- palette = reserve ? opaque + [[0, 0, 0]] : opaque
140
- [palette, opaque, reserve ? opaque.length : nil]
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
141
161
  end
142
162
 
143
163
  def write_header
@@ -147,15 +167,16 @@ module Flipbook
147
167
  @io << [0x80 | 0x70 | (bits - 1), @global_transparent_index || 0, 0].pack("C3")
148
168
  write_palette(@global_palette, size)
149
169
  else
150
- @io << "\0\0\0".b
170
+ @io << [0x80 | 0x70, 0, 0].pack("C3")
171
+ write_palette([[0, 0, 0]], 2)
151
172
  end
152
173
  if @loop
153
174
  @io << "!\xFF\x0BNETSCAPE2.0\x03\x01".b << [@loop].pack("v") << "\0".b
154
175
  end
155
176
  end
156
177
 
157
- def write_control(delay, transparent_index)
158
- packed = 0
178
+ def write_control(delay, transparent_index, disposal:)
179
+ packed = disposal << 2
159
180
  packed |= 0x01 if transparent_index
160
181
  @io << "!\xF9\x04".b << [packed, delay, transparent_index || 0, 0].pack("CvCC")
161
182
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Flipbook
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/flipbook.rb CHANGED
@@ -6,6 +6,7 @@ require_relative "flipbook/version"
6
6
  require_relative "flipbook/timing"
7
7
  require_relative "flipbook/gif/bit_writer"
8
8
  require_relative "flipbook/gif/lzw"
9
+ require_relative "flipbook/gif/reader"
9
10
  require_relative "flipbook/gif/writer"
10
11
  require_relative "flipbook/apng/writer"
11
12
  require_relative "flipbook/diff"
@@ -35,6 +36,10 @@ module Flipbook
35
36
  end
36
37
  end
37
38
 
39
+ def read(path, **options)
40
+ GIF::Reader.read(path, **options)
41
+ end
42
+
38
43
  def write_gif(path, frames, delays, loop:, colors:, palette:, dither:, optimize:)
39
44
  has_transparency = frames.any? { |frame| frame.bytes.bytes.each_slice(4).any? { |_, _, _, alpha| alpha.zero? } }
40
45
  transparent = has_transparency
data/sig/flipbook.rbs CHANGED
@@ -2,6 +2,7 @@ module Flipbook
2
2
  VERSION: String
3
3
 
4
4
  def self.write: (String path, Array[Tessel::Image] frames, ?fps: Numeric?, ?delay: Numeric | Array[Numeric]?, ?loop: bool | Integer, ?colors: Integer, ?palette: Symbol | Array[[Integer, Integer, Integer]], ?dither: Symbol, ?optimize: bool) -> String
5
+ def self.read: (String path, ?max_pixels: Integer, ?max_frames: Integer, ?max_total_pixels: Integer) -> Array[Tessel::Image]
5
6
 
6
7
  module Timing
7
8
  def self.delays: (Integer count, ?fps: Numeric?, ?delay: Numeric | Array[Numeric]?, ?warn_short: bool) -> Array[Rational]
@@ -16,6 +17,21 @@ module Flipbook
16
17
  end
17
18
  end
18
19
 
20
+ module GIF
21
+ class Writer
22
+ def self.open: (String path, width: Integer, height: Integer, ?loop: bool | Integer, ?palette: Symbol | Array[[Integer, Integer, Integer]], ?colors: Integer, ?dither: Symbol, ?transparent_index: Integer?, ?optimize: bool) { (Writer) -> void } -> String
23
+ def add: (Tessel::Image image, delay: Numeric) -> Writer
24
+ def close: () -> Writer
25
+ end
26
+
27
+ class Reader
28
+ def self.read: (String path, ?max_pixels: Integer, ?max_frames: Integer, ?max_total_pixels: Integer) -> Array[Tessel::Image]
29
+ def read: () -> Array[Tessel::Image]
30
+ end
31
+
32
+ def self.read: (String path, ?max_pixels: Integer, ?max_frames: Integer, ?max_total_pixels: Integer) -> Array[Tessel::Image]
33
+ end
34
+
19
35
  module Diff
20
36
  def self.bounds: (Tessel::Image previous, Tessel::Image current) -> [Integer, Integer, Integer, Integer]?
21
37
  end
data/sig/tessel.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Tessel
2
+ class Image
3
+ end
4
+ end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flipbook
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-09-25 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: tessel
@@ -57,8 +58,8 @@ dependencies:
57
58
  - - "~>"
58
59
  - !ruby/object:Gem::Version
59
60
  version: '3.6'
60
- description: Flipbook encodes optimized animated GIF and APNG files from Tessel RGBA
61
- images.
61
+ description: Flipbook encodes optimized GIF and APNG animations and reads composited
62
+ GIF frames from Tessel images.
62
63
  email:
63
64
  - t.yudai92@gmail.com
64
65
  executables: []
@@ -69,6 +70,11 @@ files:
69
70
  - LICENSE.txt
70
71
  - README.md
71
72
  - Rakefile
73
+ - docs/assets/gradient.gif
74
+ - docs/assets/spinning-square.gif
75
+ - docs/assets/transparent.gif
76
+ - docs/assets/transparent.png
77
+ - docs/index.html
72
78
  - docs/verification.md
73
79
  - examples/generate.rb
74
80
  - lib/flipbook.rb
@@ -76,16 +82,19 @@ files:
76
82
  - lib/flipbook/diff.rb
77
83
  - lib/flipbook/gif/bit_writer.rb
78
84
  - lib/flipbook/gif/lzw.rb
85
+ - lib/flipbook/gif/reader.rb
79
86
  - lib/flipbook/gif/writer.rb
80
87
  - lib/flipbook/timing.rb
81
88
  - lib/flipbook/version.rb
82
89
  - sig/flipbook.rbs
90
+ - sig/tessel.rbs
83
91
  homepage: https://github.com/rbgfx/flipbook
84
92
  licenses:
85
93
  - MIT
86
94
  metadata:
87
95
  source_code_uri: https://github.com/rbgfx/flipbook
88
96
  changelog_uri: https://github.com/rbgfx/flipbook/blob/main/CHANGELOG.md
97
+ post_install_message:
89
98
  rdoc_options: []
90
99
  require_paths:
91
100
  - lib
@@ -100,7 +109,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
100
109
  - !ruby/object:Gem::Version
101
110
  version: '0'
102
111
  requirements: []
103
- rubygems_version: 4.0.16
112
+ rubygems_version: 3.4.19
113
+ signing_key:
104
114
  specification_version: 4
105
- summary: Write animated GIF and APNG files in Ruby
115
+ summary: Write and read animated GIF and APNG files in Ruby
106
116
  test_files: []