flipbook 0.1.0 → 0.3.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: 4c21569bdedb373e7209ca4931199bfaa3039c62e606e3a09c5f49b708d2f3da
4
- data.tar.gz: f0f977372a0d76697924494e6b9fc63ebbf535d23a01a4b9e80858649855cf0a
3
+ metadata.gz: ed6c3ae7ae41a6819cbd15890ebe7002b9637c2a2afb51e1f185ac7cf4c4b39c
4
+ data.tar.gz: 59c4fcbf5f6fce4baa22c359a54334d873403bf2ee6904ab483770cca41a5ab1
5
5
  SHA512:
6
- metadata.gz: b814f5fbb5d1a743bb406e409b82317ddc3506acf7a8abde3f6317d2ffbba5fc1f2f9721e183fa09fb94113915a98deff2c6915983880ccf31da3002d4bf99de
7
- data.tar.gz: 32f5830b2b7a5584d8e2b21af6727a550717e888f4d8b57c0631738695a10d35feba4084a357878794b18ce4233055b8d68f118aa705b449cab7f4f78c123c40
6
+ metadata.gz: 3047e42608ce4f1d5653fa4573fc2d9c210e5b732557bb65cf2f41f8087aa8f6b81c1a3e895f4cadc3e03c8c83b532bfaa8ea51a47bd557bcf890bd0fb42e949
7
+ data.tar.gz: 44abccb00089ba9fe0d6b16657c36c2967d506a69e923e42d31971260b09461d405f755e3d195f6fd5a50aadea68971686b0ba101217d1b74bf524887183f375
data/CHANGELOG.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Changelog
2
2
 
3
- ## [0.1.0] - 2026-09-25
3
+ ## [0.3.0] - 2026-09-24
4
4
 
5
- Initial release
5
+ - Add GIF89a encoding, animated PNG encoding, and GIF decoding.
6
+ - Add streaming writers, cumulative frame timing, palette quantization, transparency, and frame-difference output.
7
+ - Add the `Flipbook.write` and `Flipbook.read` APIs.
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <h1 align="center">Flipbook</h1>
2
2
 
3
- <p align="center">Turn a sequence of RGBA images into animated GIF files.</p>
3
+ <p align="center">Turn a sequence of RGBA images into animated GIF or APNG files.</p>
4
4
 
5
5
  <p align="center">
6
6
  <a href="https://rubygems.org/gems/flipbook"><img src="https://badge.fury.io/rb/flipbook.svg" alt="Gem version"></a>
@@ -12,15 +12,16 @@
12
12
 
13
13
  ---
14
14
 
15
- Flipbook writes animated GIFs from [`Tessel::Image`](https://github.com/rbgfx/tessel) frames. GIF palettes use Tessel's shared quantizer. The library has no runtime dependency beyond Tessel and Ruby's standard library.
15
+ Flipbook writes animated GIF and APNG from [`Tessel::Image`](https://github.com/rbgfx/tessel) frames. GIF palettes use Tessel's shared quantizer. The library has no runtime dependency beyond Tessel and Ruby's standard library.
16
16
 
17
17
  ## Formats
18
18
 
19
- | Format | Colors | Transparency | Encoding |
19
+ | Format | Color | Transparency | Reading |
20
20
  | --- | --- | --- | --- |
21
- | GIF | Up to 256 palette entries | One transparent palette index | GIF89a output with global or per-frame palettes |
21
+ | GIF | Up to 256 palette entries | One transparent palette index | GIF87a/GIF89a frames, local and global palettes, interlace, and disposal methods |
22
+ | APNG | RGBA8 | Full alpha | First frame through Tessel's PNG reader |
22
23
 
23
- GIF uses a single global palette by default so colors stay stable across frames. Use `palette: :per_frame` when streaming frames with different color ranges.
24
+ GIF uses a single global palette by default so colors stay stable across frames. Animated frames with transparent pixels are written as full frames with background disposal so pixels can become transparent correctly. Opaque animations use difference rectangles by default.
24
25
 
25
26
  ## Installation
26
27
 
@@ -42,6 +43,9 @@ frames = 60.times.map do |index|
42
43
  end
43
44
 
44
45
  Flipbook.write("animation.gif", frames, fps: 30, loop: true)
46
+ Flipbook.write("animation.png", frames, fps: 30, loop: true)
47
+
48
+ decoded_frames = Flipbook.read("animation.gif")
45
49
  ~~~
46
50
 
47
51
  Use `delay:` in seconds instead of `fps:`. It accepts one duration or one duration per frame; `Rational(1, 30)` avoids floating-point timing drift. GIF delays use cumulative centisecond rounding. Viewers may lengthen delays below 1/50 second.
@@ -54,11 +58,15 @@ Flipbook::GIF::Writer.open("recording.gif", width: 320, height: 240, palette: :p
54
58
  end
55
59
  ~~~
56
60
 
61
+ APNG also supports streaming output and patches its frame count when the destination can seek. Otherwise it buffers the animation until close.
62
+
57
63
  ### API contracts
58
64
 
59
- - `Flipbook.write(path, frames, fps: or delay:)` requires one or more same-sized `Tessel::Image` objects and a `.gif` output path.
60
- - `GIF::Writer#add` raises `TypeError` for non-image frames and `ArgumentError` for invalid dimensions or timing.
65
+ - `Flipbook.write(path, frames, fps: or delay:)` requires one or more same-sized `Tessel::Image` objects. The `.gif` extension selects GIF; `.png` and `.apng` select APNG.
66
+ - `Flipbook.read(path)` returns composited GIF frames as `Tessel::Image` objects. `Flipbook::GIF::Reader#delays` returns each frame delay as a `Rational` in seconds.
67
+ - `GIF::Writer#add` and `APNG::Writer#add` raise `TypeError` for non-image frames and `ArgumentError` for invalid dimensions or timing.
61
68
  - 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.
69
+ - APNG chunks use RGBA8 pixels. The first frame is also a valid static PNG image.
62
70
 
63
71
  GIF format acknowledgement: The Graphics Interchange Format© is the Copyright property of CompuServe Incorporated. GIF® is a Service Mark property of CompuServe Incorporated.
64
72
 
data/docs/verification.md CHANGED
@@ -1,12 +1,13 @@
1
1
  # File verification
2
2
 
3
- The test suite checks GIF LZW code-width growth and dictionary resets, frame timing, and transparent palette entries.
3
+ The test suite checks GIF LZW code-width growth and dictionary resets, decodes generated animations, and validates APNG chunk ordering and sequence numbers.
4
4
 
5
5
  For an independent local check, install ImageMagick or gifsicle and inspect sample output:
6
6
 
7
7
  ~~~sh
8
- identify animation.gif
8
+ identify animation.gif animation.png
9
9
  magick animation.gif -coalesce /tmp/flipbook-gif-%02d.png
10
+ magick animation.png -coalesce /tmp/flipbook-apng-%02d.png
10
11
  gifsicle --info animation.gif
11
12
  ~~~
12
13
 
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Flipbook
6
+ module APNG
7
+ class Writer
8
+ def self.open(path, **options)
9
+ io = File.open(path, "wb+")
10
+ writer = new(io, **options)
11
+ writer.instance_variable_set(:@owns_io, true)
12
+ return writer unless block_given?
13
+
14
+ begin
15
+ yield writer
16
+ ensure
17
+ writer.close
18
+ end
19
+ path
20
+ rescue StandardError
21
+ io&.close unless io&.closed?
22
+ raise
23
+ end
24
+
25
+ def initialize(io, width:, height:, loop: true, optimize: true, level: Zlib::DEFAULT_COMPRESSION, filter: :none)
26
+ @io = io
27
+ @width = Integer(width)
28
+ @height = Integer(height)
29
+ raise ArgumentError, "APNG dimensions must be positive" unless @width.positive? && @height.positive?
30
+ raise ArgumentError, "unknown PNG filter: #{filter}" unless %i[none sub up average paeth adaptive].include?(filter)
31
+
32
+ @loop = plays(loop)
33
+ @optimize = optimize
34
+ @level = level
35
+ @filter = filter
36
+ @seekable = io.respond_to?(:pos) && io.respond_to?(:seek)
37
+ @storage = @seekable ? io : StringIO.new("".b)
38
+ @frames = 0
39
+ @sequence = 0
40
+ @previous = nil
41
+ @closed = false
42
+ @storage << Tessel::SIGNATURE
43
+ Tessel::PNG::Chunk.write(@storage, "IHDR", [@width, @height, 8, 6, 0, 0, 0].pack("N2C5"))
44
+ @animation_offset = @storage.pos
45
+ write_chunk("acTL", [0, @loop].pack("N2"))
46
+ end
47
+
48
+ def add(image, delay:)
49
+ raise IOError, "APNG writer is closed" if @closed
50
+ raise TypeError, "frame must be a Tessel::Image" unless image.is_a?(Tessel::Image)
51
+ raise ArgumentError, "frame dimensions must match the animation" unless image.width == @width && image.height == @height
52
+
53
+ seconds = Timing.duration(delay)
54
+ left, top, width, height = @optimize ? Diff.bounds(@previous, image) : [0, 0, @width, @height]
55
+ frame = image.crop(left, top, width, height)
56
+ numerator, denominator = Timing.apng_fraction(seconds)
57
+ compressed = idat_data(frame)
58
+ raise Error, "APNG frame count exceeds the format limit" if @frames >= 0xFFFF_FFFF
59
+ control = [@sequence, width, height, left, top, numerator, denominator, 0, 0].pack("N5n2C2")
60
+ write_chunk("fcTL", control)
61
+ @sequence += 1
62
+ if @frames.zero?
63
+ write_chunk("IDAT", compressed)
64
+ else
65
+ write_chunk("fdAT", [@sequence].pack("N") + compressed)
66
+ @sequence += 1
67
+ end
68
+ @frames += 1
69
+ @previous = image.dup
70
+ self
71
+ end
72
+
73
+ def close
74
+ return if @closed
75
+ raise Error, "APNG contains no frames" if @frames.zero?
76
+
77
+ write_chunk("IEND", "".b)
78
+ finish_animation_control
79
+ @io << @storage.string if @storage != @io
80
+ @closed = true
81
+ @io.close if @owns_io && !@io.closed?
82
+ self
83
+ end
84
+
85
+ private
86
+
87
+ def plays(value)
88
+ return 0 if value == true
89
+ return 1 if value == false
90
+ return value if value.is_a?(Integer) && value.between?(0, 0xFFFF_FFFF)
91
+
92
+ raise ArgumentError, "loop must be true, false, or an integer from 0 to 4294967295"
93
+ end
94
+
95
+ def write_chunk(type, data)
96
+ Tessel::PNG::Chunk.write(@storage, type, data)
97
+ end
98
+
99
+ def idat_data(image)
100
+ png = Tessel::PNG.encode(image, color_type: :rgba, filter: @filter, level: @level, metadata: {})
101
+ output = "".b
102
+ cursor = Tessel::SIGNATURE.bytesize
103
+ while cursor + 12 <= png.bytesize
104
+ length = png.byteslice(cursor, 4).unpack1("N")
105
+ type = png.byteslice(cursor + 4, 4)
106
+ output << png.byteslice(cursor + 8, length) if type == "IDAT"
107
+ cursor += length + 12
108
+ break if type == "IEND"
109
+ end
110
+ raise Error, "Tessel produced a PNG without image data" if output.empty?
111
+
112
+ output
113
+ end
114
+
115
+ def finish_animation_control
116
+ control = [@frames, @loop].pack("N2")
117
+ ending = @storage.pos
118
+ @storage.seek(@animation_offset)
119
+ write_chunk("acTL", control)
120
+ @storage.seek(ending)
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flipbook
4
+ module Diff
5
+ module_function
6
+
7
+ def bounds(previous, current)
8
+ raise TypeError, "current must be a Tessel::Image" unless current.is_a?(Tessel::Image)
9
+ return [0, 0, current.width, current.height] unless previous
10
+ raise TypeError, "previous must be a Tessel::Image" unless previous.is_a?(Tessel::Image)
11
+ raise ArgumentError, "image dimensions must match" unless previous.width == current.width && previous.height == current.height
12
+
13
+ old_bytes = previous.bytes
14
+ new_bytes = current.bytes
15
+ left, top, right, bottom = current.width, current.height, -1, -1
16
+ (0...(current.width * current.height)).each do |index|
17
+ offset = index * 4
18
+ next if old_bytes.byteslice(offset, 4) == new_bytes.byteslice(offset, 4)
19
+
20
+ x = index % current.width
21
+ y = index / current.width
22
+ left = x if x < left
23
+ top = y if y < top
24
+ right = x if x > right
25
+ bottom = y if y > bottom
26
+ end
27
+ right.negative? ? [0, 0, 1, 1] : [left, top, right - left + 1, bottom - top + 1]
28
+ end
29
+ end
30
+ end
@@ -46,6 +46,72 @@ module Flipbook
46
46
  writer.sub_blocks
47
47
  end
48
48
 
49
+ def decode(bytes, minimum_code_size, expected_size: nil)
50
+ raise ArgumentError, "minimum code size must be between 2 and 8" unless minimum_code_size.is_a?(Integer) && minimum_code_size.between?(2, 8)
51
+
52
+ bytes = String(bytes).b
53
+ clear_code = 1 << minimum_code_size
54
+ end_code = clear_code + 1
55
+ dictionary = initial_dictionary(clear_code)
56
+ next_code = end_code + 1
57
+ code_width = minimum_code_size + 1
58
+ bit_offset = 0
59
+ previous = nil
60
+ output = "".b
61
+ ended = false
62
+ loop do
63
+ code = read_code(bytes, bit_offset, code_width)
64
+ break if code.nil?
65
+ bit_offset += code_width
66
+ if code == clear_code
67
+ dictionary = initial_dictionary(clear_code)
68
+ next_code = end_code + 1
69
+ code_width = minimum_code_size + 1
70
+ previous = nil
71
+ next
72
+ end
73
+ if code == end_code
74
+ ended = true
75
+ break
76
+ end
77
+
78
+ entry = if code < dictionary.length && dictionary[code]
79
+ dictionary[code]
80
+ elsif code == next_code && previous
81
+ previous + [previous.first]
82
+ else
83
+ raise Error, "invalid GIF LZW code"
84
+ end
85
+ output << entry.pack("C*")
86
+ raise Error, "GIF frame expands beyond its dimensions" if expected_size && output.bytesize > expected_size
87
+
88
+ if previous && next_code < 4096
89
+ dictionary[next_code] = previous + [entry.first]
90
+ next_code += 1
91
+ code_width += 1 if next_code == (1 << code_width) && code_width < 12
92
+ end
93
+ previous = entry
94
+ end
95
+ raise Error, "GIF LZW data is truncated" unless ended && (!expected_size || output.bytesize == expected_size)
96
+
97
+ output
98
+ end
99
+
100
+ def initial_dictionary(clear_code)
101
+ Array.new(clear_code) { |index| [index] } + [nil, nil]
102
+ end
103
+ private_class_method :initial_dictionary
104
+
105
+ def read_code(bytes, bit_offset, width)
106
+ return nil if bit_offset + width > bytes.bytesize * 8
107
+
108
+ value = 0
109
+ width.times do |bit|
110
+ value |= ((bytes.getbyte((bit_offset + bit) / 8) >> ((bit_offset + bit) % 8)) & 1) << bit
111
+ end
112
+ value
113
+ end
114
+ private_class_method :read_code
49
115
  end
50
116
  end
51
117
  end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flipbook
4
+ module GIF
5
+ class Reader
6
+ attr_reader :width, :height
7
+
8
+ def self.read(path)
9
+ new(File.binread(path)).frames
10
+ end
11
+
12
+ def initialize(data, max_pixels: 16_777_216, max_frames: 10_000, max_total_pixels: 67_108_864)
13
+ @data = String(data).b
14
+ @cursor = 0
15
+ @max_pixels = max_pixels
16
+ @max_frames = max_frames
17
+ @max_total_pixels = max_total_pixels
18
+ @decoded_pixels = 0
19
+ @frames = []
20
+ @delays = []
21
+ decode
22
+ end
23
+
24
+ def frames
25
+ @frames.map(&:dup).freeze
26
+ end
27
+
28
+ def delays
29
+ @delays.dup.freeze
30
+ end
31
+
32
+ def each(&block)
33
+ return enum_for(:each) unless block
34
+
35
+ @frames.each(&block)
36
+ end
37
+
38
+ private
39
+
40
+ def decode
41
+ raise Error, "invalid GIF signature" unless take(6).match?(/\AGIF8[79]a\z/)
42
+
43
+ @width, @height, packed, @background_index, = take(7).unpack("v2C3")
44
+ raise Error, "invalid GIF dimensions" unless @width.positive? && @height.positive?
45
+ raise Error, "GIF exceeds pixel limit" if @width * @height > @max_pixels
46
+
47
+ global = (packed & 0x80).zero? ? nil : read_palette(1 << ((packed & 7) + 1))
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 }
55
+
56
+ until @cursor >= @data.bytesize
57
+ marker = byte
58
+ case marker
59
+ when 0x3B then break
60
+ when 0x21 then read_extension(control)
61
+ when 0x2C
62
+ raise Error, "GIF exceeds frame limit" if @frames.length >= @max_frames
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"
85
+ end
86
+ end
87
+ raise Error, "GIF contains no frames" if @frames.empty?
88
+ end
89
+
90
+ def read_extension(control)
91
+ label = byte
92
+ if label == 0xF9
93
+ raise Error, "invalid GIF control extension" unless byte == 4
94
+ packed, delay, transparent = take(4).unpack("CvC")
95
+ raise Error, "invalid GIF control terminator" unless byte.zero?
96
+ control.replace(disposal: (packed >> 2) & 7, delay: delay, transparent: (packed & 1).zero? ? nil : transparent)
97
+ else
98
+ skip_sub_blocks
99
+ end
100
+ end
101
+
102
+ def read_frame(global)
103
+ left, top, width, height, packed = take(9).unpack("v4C")
104
+ raise Error, "invalid GIF frame dimensions" unless width.positive? && height.positive?
105
+ raise Error, "GIF frame exceeds pixel limit" if width * height > @max_pixels
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
108
+
109
+ local = (packed & 0x80).zero? ? nil : read_palette(1 << ((packed & 7) + 1))
110
+ palette = local || global
111
+ raise Error, "GIF frame has no color table" unless palette
112
+ minimum = byte
113
+ compressed = read_sub_blocks
114
+ indices = LZW.decode(compressed, minimum, expected_size: width * height)
115
+ indices = deinterlace(indices, width, height) unless (packed & 0x40).zero?
116
+ @decoded_pixels += width * height
117
+ { left:, top:, width:, height:, palette:, indices:, control: @current_control.dup }
118
+ end
119
+
120
+ def composite(canvas, frame)
121
+ frame[:height].times do |row|
122
+ frame[:width].times do |column|
123
+ index = frame[:indices].getbyte(row * frame[:width] + column)
124
+ next if index == frame[:control][:transparent]
125
+
126
+ color = frame[:palette][index]
127
+ raise Error, "GIF color index out of range" unless color
128
+ canvas[frame[:left] + column, frame[:top] + row] = [*color, 255]
129
+ end
130
+ end
131
+ end
132
+
133
+ def control_background(global, background, transparent)
134
+ return [0, 0, 0, 0] if transparent == @background_index
135
+ return [0, 0, 0, 0] unless global && background
136
+
137
+ [*background, 255]
138
+ end
139
+
140
+ def deinterlace(indices, width, height)
141
+ rows = [0, 4, 2, 1].flat_map do |start|
142
+ step = { 0 => 8, 4 => 8, 2 => 4, 1 => 2 }.fetch(start)
143
+ (start...height).step(step).to_a
144
+ end
145
+ output = "\0".b * indices.bytesize
146
+ rows.each_with_index do |row, source_row|
147
+ output[row * width, width] = indices.byteslice(source_row * width, width)
148
+ end
149
+ output
150
+ end
151
+
152
+ def read_palette(count)
153
+ take(count * 3).bytes.each_slice(3).map(&:freeze)
154
+ end
155
+
156
+ def skip_sub_blocks
157
+ read_sub_blocks
158
+ end
159
+
160
+ def read_sub_blocks
161
+ output = "".b
162
+ loop do
163
+ length = byte
164
+ break if length.zero?
165
+ raise Error, "GIF sub-block exceeds input" if @cursor + length > @data.bytesize
166
+
167
+ output << take(length)
168
+ end
169
+ output
170
+ end
171
+
172
+ def byte
173
+ raise Error, "truncated GIF" if @cursor >= @data.bytesize
174
+
175
+ value = @data.getbyte(@cursor)
176
+ @cursor += 1
177
+ value
178
+ end
179
+
180
+ def take(length)
181
+ raise Error, "truncated GIF" if length.negative? || @cursor + length > @data.bytesize
182
+
183
+ value = @data.byteslice(@cursor, length)
184
+ @cursor += length
185
+ value
186
+ end
187
+ end
188
+ end
189
+ end
@@ -20,7 +20,7 @@ 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, transparent_index: nil)
23
+ def initialize(io, width:, height:, loop: true, palette: :per_frame, colors: 256, dither: :floyd_steinberg, optimize: true, transparent_index: nil, clear_background: false)
24
24
  @io = io
25
25
  @width = Integer(width)
26
26
  @height = Integer(height)
@@ -31,6 +31,9 @@ module Flipbook
31
31
  @loop = loop_value(loop)
32
32
  @colors = colors
33
33
  @dither = dither
34
+ @clear_background = clear_background
35
+ @optimize = optimize && !clear_background
36
+ @previous = nil
34
37
  @elapsed = Rational(0)
35
38
  @rounded_delay = 0
36
39
  @closed = false
@@ -38,11 +41,17 @@ module Flipbook
38
41
  @frame_count = 0
39
42
  @palette_mode = palette
40
43
  if palette == :per_frame
44
+ raise ArgumentError, "clear_background requires a global palette" if clear_background
41
45
  raise ArgumentError, "transparent_index is only valid with a global palette" unless transparent_index.nil?
42
46
  @global_palette = nil
43
47
  else
44
48
  raise TypeError, "palette must be :per_frame or an RGB array" unless palette.is_a?(Array)
45
49
  @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
46
55
  if transparent_index
47
56
  raise ArgumentError, "transparent_index must be the last palette entry" unless transparent_index == @global_palette.length - 1
48
57
  raise ArgumentError, "transparent_index is out of range" unless transparent_index.between?(0, @global_palette.length - 1)
@@ -64,7 +73,9 @@ module Flipbook
64
73
  @warned_short_delay = true
65
74
  end
66
75
  transparent_pixels = image.bytes.bytes.each_slice(4).any? { |_, _, _, alpha| alpha.zero? }
67
- raise ArgumentError, "transparent frames require a global palette" if @palette_mode == :per_frame && transparent_pixels
76
+ if @palette_mode == :per_frame && @frame_count.positive? && (@previous_transparency || transparent_pixels)
77
+ raise ArgumentError, "transparent multi-frame GIFs require a global palette"
78
+ end
68
79
  elapsed = @elapsed + seconds
69
80
  target_delay = (elapsed * 100).floor
70
81
  frame_delay = target_delay - @rounded_delay
@@ -78,11 +89,25 @@ module Flipbook
78
89
  end
79
90
  end
80
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
102
+ end
103
+ frame_indices = crop_indices(indices, left, top, width, height)
81
104
  write_control(frame_delay, transparent_index)
82
- write_image(0, 0, @width, @height, indices, palette)
105
+ write_image(left, top, width, height, frame_indices, palette)
83
106
  @elapsed = elapsed
84
107
  @rounded_delay = target_delay
85
108
  @frame_count += 1
109
+ @previous_transparency = transparent_pixels
110
+ @previous = image.dup
86
111
  self
87
112
  end
88
113
 
@@ -123,7 +148,7 @@ module Flipbook
123
148
  return [@global_palette, opaque, @global_transparent_index]
124
149
  end
125
150
 
126
- reserve = transparent_pixels
151
+ reserve = @optimize || transparent_pixels
127
152
  opaque_count = reserve ? [@colors - 1, 1].max : @colors
128
153
  opaque = Tessel::Quantize.palette_for(image, colors: [opaque_count, 2].max).first(opaque_count)
129
154
  palette = reserve ? opaque + [[0, 0, 0]] : opaque
@@ -134,7 +159,8 @@ module Flipbook
134
159
  @io << "GIF89a".b << [@width, @height].pack("v2")
135
160
  if @global_palette
136
161
  bits, size = table_size(@global_palette.length)
137
- @io << [0x80 | 0x70 | (bits - 1), 0, 0].pack("C3")
162
+ background = @clear_background ? @global_transparent_index : 0
163
+ @io << [0x80 | 0x70 | (bits - 1), background, 0].pack("C3")
138
164
  write_palette(@global_palette, size)
139
165
  else
140
166
  @io << "\0\0\0".b
@@ -145,7 +171,7 @@ module Flipbook
145
171
  end
146
172
 
147
173
  def write_control(delay, transparent_index)
148
- packed = 0
174
+ packed = @clear_background ? 0x08 : (@optimize ? 0x04 : 0)
149
175
  packed |= 0x01 if transparent_index
150
176
  @io << "!\xF9\x04".b << [packed, delay, transparent_index || 0, 0].pack("CvCC")
151
177
  end
@@ -170,6 +196,13 @@ module Flipbook
170
196
  (size - palette.length).times { @io << "\0\0\0".b }
171
197
  end
172
198
 
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
173
206
  end
174
207
  end
175
208
  end
@@ -39,6 +39,17 @@ 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
+
42
53
  def rational(value)
43
54
  raise TypeError, "delay and fps must be numeric" unless value.is_a?(Numeric)
44
55
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Flipbook
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/flipbook.rb CHANGED
@@ -1,32 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "tessel"
4
+ require "zlib"
4
5
 
5
6
  require_relative "flipbook/version"
6
7
  require_relative "flipbook/timing"
8
+ require_relative "flipbook/diff"
7
9
  require_relative "flipbook/gif/bit_writer"
8
10
  require_relative "flipbook/gif/lzw"
9
11
  require_relative "flipbook/gif/writer"
12
+ require_relative "flipbook/gif/reader"
13
+ require_relative "flipbook/apng/writer"
10
14
 
11
15
  module Flipbook
12
16
  class Error < StandardError; end
13
17
 
14
18
  module_function
15
19
 
16
- def write(path, frames, fps: nil, delay: nil, loop: true, colors: 256, palette: :global, dither: :floyd_steinberg)
20
+ def write(path, frames, fps: nil, delay: nil, loop: true, format: nil, colors: 256, palette: :global, dither: :floyd_steinberg, optimize: true, **options)
17
21
  raise TypeError, "frames must be an array of Tessel::Image values" unless frames.is_a?(Array) && frames.all? { |frame| frame.is_a?(Tessel::Image) }
18
22
  raise ArgumentError, "at least one frame is required" if frames.empty?
19
23
  raise ArgumentError, "frame dimensions must match" unless frames.all? { |frame| frame.width == frames.first.width && frame.height == frames.first.height }
20
24
 
21
25
  delays = Timing.delays(frames.length, fps:, delay:)
22
- raise ArgumentError, "output path must end in .gif" unless File.extname(String(path)).casecmp?(".gif")
26
+ extension = format&.to_s&.downcase || File.extname(String(path)).delete_prefix(".").downcase
27
+ case extension
28
+ when "gif"
29
+ write_gif(path, frames, delays, loop:, colors:, palette:, dither:, optimize:)
30
+ when "png", "apng"
31
+ APNG::Writer.open(path, width: frames.first.width, height: frames.first.height, loop:, optimize:, **options) do |writer|
32
+ frames.zip(delays).each { |frame, frame_delay| writer.add(frame, delay: frame_delay) }
33
+ end
34
+ else
35
+ raise ArgumentError, "format must be GIF or APNG"
36
+ end
37
+ end
38
+
39
+ def read(path)
40
+ bytes = File.binread(path)
41
+ raise Error, "not a GIF file" unless bytes.start_with?("GIF87a", "GIF89a")
23
42
 
24
- write_gif(path, frames, delays, loop:, colors:, palette:, dither:)
43
+ GIF::Reader.new(bytes).frames
25
44
  end
26
45
 
27
- def write_gif(path, frames, delays, loop:, colors:, palette:, dither:)
46
+ def write_gif(path, frames, delays, loop:, colors:, palette:, dither:, optimize:)
28
47
  has_transparency = frames.any? { |frame| frame.bytes.bytes.each_slice(4).any? { |_, _, _, alpha| alpha.zero? } }
29
- transparent = has_transparency
48
+ transparent = optimize || has_transparency
30
49
  global_palette = if palette == :per_frame
31
50
  raise ArgumentError, "per-frame palettes cannot preserve transparency across frames" if has_transparency
32
51
  :per_frame
@@ -46,7 +65,7 @@ module Flipbook
46
65
  end
47
66
  transparent_index = global_palette.is_a?(Array) && transparent ? global_palette.length - 1 : nil
48
67
 
49
- GIF::Writer.open(path, width: frames.first.width, height: frames.first.height, loop:, palette: global_palette, colors:, dither:, transparent_index:) do |writer|
68
+ GIF::Writer.open(path, width: frames.first.width, height: frames.first.height, loop:, palette: global_palette, colors:, dither:, optimize:, transparent_index:, clear_background: has_transparency) do |writer|
50
69
  frames.zip(delays).each { |frame, frame_delay| writer.add(frame, delay: frame_delay) }
51
70
  end
52
71
  end
data/sig/flipbook.rbs CHANGED
@@ -1,10 +1,12 @@
1
1
  module Flipbook
2
2
  VERSION: String
3
3
 
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) -> String
4
+ def self.write: (String path, Array[Tessel::Image] frames, ?fps: Numeric?, ?delay: Numeric | Array[Numeric]?, ?loop: bool | Integer, ?format: String?, ?colors: Integer, ?palette: Symbol | Array[[Integer, Integer, Integer]], ?dither: Symbol, ?optimize: bool, **untyped) -> String
5
+ def self.read: (String path) -> Array[Tessel::Image]
5
6
 
6
7
  module Timing
7
8
  def self.delays: (Integer count, ?fps: Numeric?, ?delay: Numeric | Array[Numeric]?) -> Array[Rational]
8
9
  def self.centiseconds: (Array[Numeric] delays) -> Array[Integer]
10
+ def self.apng_fraction: (Numeric delay) -> [Integer, Integer]
9
11
  end
10
12
  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.1.0
4
+ version: 0.3.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,7 +58,7 @@ dependencies:
57
58
  - - "~>"
58
59
  - !ruby/object:Gem::Version
59
60
  version: '3.6'
60
- description: Flipbook encodes animated GIF files from Tessel RGBA images.
61
+ description: Flipbook encodes animated GIF and APNG files from Tessel RGBA images.
61
62
  email:
62
63
  - t.yudai92@gmail.com
63
64
  executables: []
@@ -70,8 +71,11 @@ files:
70
71
  - Rakefile
71
72
  - docs/verification.md
72
73
  - lib/flipbook.rb
74
+ - lib/flipbook/apng/writer.rb
75
+ - lib/flipbook/diff.rb
73
76
  - lib/flipbook/gif/bit_writer.rb
74
77
  - lib/flipbook/gif/lzw.rb
78
+ - lib/flipbook/gif/reader.rb
75
79
  - lib/flipbook/gif/writer.rb
76
80
  - lib/flipbook/timing.rb
77
81
  - lib/flipbook/version.rb
@@ -82,6 +86,7 @@ licenses:
82
86
  metadata:
83
87
  source_code_uri: https://github.com/rbgfx/flipbook
84
88
  changelog_uri: https://github.com/rbgfx/flipbook/blob/main/CHANGELOG.md
89
+ post_install_message:
85
90
  rdoc_options: []
86
91
  require_paths:
87
92
  - lib
@@ -96,7 +101,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
96
101
  - !ruby/object:Gem::Version
97
102
  version: '0'
98
103
  requirements: []
99
- rubygems_version: 4.0.16
104
+ rubygems_version: 3.4.19
105
+ signing_key:
100
106
  specification_version: 4
101
- summary: Write animated GIFs in Ruby
107
+ summary: Write and read GIF and APNG animations in Ruby
102
108
  test_files: []