flipbook 0.2.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: 21a1c656c9c85bbfdc6ace9cc7c09f3cbc8546c4f716d899d9bc72a1ec42e30b
4
- data.tar.gz: fd9bda09b22c184e7f95933a9a795edfe4739e965eb1d0fc254d7eb35705a14b
3
+ metadata.gz: ed6c3ae7ae41a6819cbd15890ebe7002b9637c2a2afb51e1f185ac7cf4c4b39c
4
+ data.tar.gz: 59c4fcbf5f6fce4baa22c359a54334d873403bf2ee6904ab483770cca41a5ab1
5
5
  SHA512:
6
- metadata.gz: fdd1e8f6929f091b6dad1e8de5fc892ae6e17704d53ac564911962a2671c70e3f2864d751860ea66be5908b065a2276256b2a38e1ef6b4cb35db15fe68a6773c
7
- data.tar.gz: cd3b12ca02ee239a641db540dede16bcc59647087e083a1cf78205d3b01ec2364c961d412dd9426ced032a839366d98c703f8d1b890b9a1e9d93338d02c5a91c
6
+ metadata.gz: 3047e42608ce4f1d5653fa4573fc2d9c210e5b732557bb65cf2f41f8087aa8f6b81c1a3e895f4cadc3e03c8c83b532bfaa8ea51a47bd557bcf890bd0fb42e949
7
+ data.tar.gz: 44abccb00089ba9fe0d6b16657c36c2967d506a69e923e42d31971260b09461d405f755e3d195f6fd5a50aadea68971686b0ba101217d1b74bf524887183f375
data/CHANGELOG.md CHANGED
@@ -1,10 +1,7 @@
1
1
  # Changelog
2
2
 
3
- ## [0.2.0] - 2026-09-25
3
+ ## [0.3.0] - 2026-09-24
4
4
 
5
- - Add APNG writing with rational frame delays, alpha, and changed-region cropping.
6
- - Crop unchanged regions in opaque GIF frames to reduce animation size.
7
-
8
- ## [0.1.0] - 2026-09-25
9
-
10
- 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">Write optimized animated GIF and APNG files from Ruby images.</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>
@@ -8,20 +8,20 @@
8
8
  <a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-750014.svg" alt="License"></a>
9
9
  </p>
10
10
 
11
- [Formats](#formats) · [Installation](#installation) · [API](#api) · [Examples](#examples) · [Development](#development)
11
+ [Formats](#formats) · [Installation](#installation) · [API](#api) · [Development](#development)
12
12
 
13
13
  ---
14
14
 
15
- Flipbook writes animated GIFs and APNGs from [`Tessel::Image`](https://github.com/rbgfx/tessel) frames. GIF palettes use Tessel's shared quantizer; APNG keeps full RGBA color. Both writers can crop unchanged frame regions.
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 and LZW |
22
- | APNG (`.png`, `.apng`) | Full RGBA | Full alpha | PNG-compatible first frame, `acTL` / `fcTL` / `fdAT` animation chunks |
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 |
23
23
 
24
- 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. Delta cropping is most effective for opaque GIF frames; APNG supports alpha-safe cropped updates.
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.
25
25
 
26
26
  ## Installation
27
27
 
@@ -43,6 +43,9 @@ frames = 60.times.map do |index|
43
43
  end
44
44
 
45
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")
46
49
  ~~~
47
50
 
48
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.
@@ -55,30 +58,15 @@ Flipbook::GIF::Writer.open("recording.gif", width: 320, height: 240, palette: :p
55
58
  end
56
59
  ~~~
57
60
 
58
- Use a `.png` or `.apng` path for full-color animation. The first frame remains a valid PNG for viewers that do not animate APNG:
59
-
60
- ~~~ruby
61
- Flipbook.write("animation.png", frames, fps: 30, loop: true)
62
- ~~~
63
-
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.
61
+ APNG also supports streaming output and patches its frame count when the destination can seek. Otherwise it buffers the animation until close.
65
62
 
66
63
  ### API contracts
67
64
 
68
- - `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
- - `GIF::Writer#add` raises `TypeError` for non-image frames and `ArgumentError` for invalid dimensions or timing.
70
- - `APNG::Writer#add` accepts positive numeric delays in seconds and raises for frames that do not match the canvas dimensions.
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.
71
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.
72
-
73
- ## Examples
74
-
75
- Generate a rotating square, a color gradient, and transparent GIF/APNG samples:
76
-
77
- ~~~sh
78
- ruby examples/generate.rb
79
- ~~~
80
-
81
- Pass an output directory as the first argument to choose where the files are written.
69
+ - APNG chunks use RGBA8 pixels. The first frame is also a valid static PNG image.
82
70
 
83
71
  GIF format acknowledgement: The Graphics Interchange Format© is the Copyright property of CompuServe Incorporated. GIF® is a Service Mark property of CompuServe Incorporated.
84
72
 
data/docs/verification.md CHANGED
@@ -1,14 +1,14 @@
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, 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
- magick identify animation.png
12
12
  ~~~
13
13
 
14
14
  These tools are optional and are not runtime dependencies.
@@ -6,7 +6,7 @@ module Flipbook
6
6
  module APNG
7
7
  class Writer
8
8
  def self.open(path, **options)
9
- io = File.open(path, "wb")
9
+ io = File.open(path, "wb+")
10
10
  writer = new(io, **options)
11
11
  writer.instance_variable_set(:@owns_io, true)
12
12
  return writer unless block_given?
@@ -22,54 +22,61 @@ module Flipbook
22
22
  raise
23
23
  end
24
24
 
25
- def initialize(io, width:, height:, loop: true, optimize: true, buffered: nil)
25
+ def initialize(io, width:, height:, loop: true, optimize: true, level: Zlib::DEFAULT_COMPRESSION, filter: :none)
26
26
  @io = io
27
27
  @width = Integer(width)
28
28
  @height = Integer(height)
29
- raise ArgumentError, "APNG dimensions must be between 1 and 2147483647" unless @width.between?(1, 2_147_483_647) && @height.between?(1, 2_147_483_647)
30
- raise TypeError, "optimize must be true or false" unless [true, false].include?(optimize)
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
31
 
32
- @loop = loop_count(loop)
32
+ @loop = plays(loop)
33
33
  @optimize = optimize
34
- @buffered = buffered.nil? ? !seekable?(io) : buffered
35
- @frames = []
36
- @previous_image = nil
37
- @frame_count = 0
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
38
39
  @sequence = 0
40
+ @previous = nil
39
41
  @closed = false
40
- write_header unless @buffered
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"))
41
46
  end
42
47
 
43
48
  def add(image, delay:)
44
49
  raise IOError, "APNG writer is closed" if @closed
45
50
  raise TypeError, "frame must be a Tessel::Image" unless image.is_a?(Tessel::Image)
46
- raise ArgumentError, "frame dimensions must match the canvas" unless image.width == @width && image.height == @height
51
+ raise ArgumentError, "frame dimensions must match the animation" unless image.width == @width && image.height == @height
47
52
 
48
53
  seconds = Timing.duration(delay)
49
- if @buffered
50
- @frames << [image.dup, seconds]
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)
51
64
  else
52
- write_frame(image, seconds)
65
+ write_chunk("fdAT", [@sequence].pack("N") + compressed)
66
+ @sequence += 1
53
67
  end
54
- @frame_count += 1
55
- @previous_image = image.dup
68
+ @frames += 1
69
+ @previous = image.dup
56
70
  self
57
71
  end
58
72
 
59
73
  def close
60
- return self if @closed
61
- raise Error, "APNG contains no frames" if @frame_count.zero?
74
+ return if @closed
75
+ raise Error, "APNG contains no frames" if @frames.zero?
62
76
 
63
- if @buffered
64
- output = StringIO.new("".b)
65
- writer = self.class.new(output, width: @width, height: @height, loop: @loop, optimize: @optimize, buffered: false)
66
- @frames.each { |image, delay| writer.add(image, delay:) }
67
- writer.close
68
- @io.write(output.string)
69
- else
70
- write_chunk("IEND", "".b)
71
- patch_animation_control
72
- end
77
+ write_chunk("IEND", "".b)
78
+ finish_animation_control
79
+ @io << @storage.string if @storage != @io
73
80
  @closed = true
74
81
  @io.close if @owns_io && !@io.closed?
75
82
  self
@@ -77,90 +84,40 @@ module Flipbook
77
84
 
78
85
  private
79
86
 
80
- def seekable?(io)
81
- io.respond_to?(:pos) && io.respond_to?(:seek)
82
- rescue IOError, SystemCallError
83
- false
84
- end
85
-
86
- def loop_count(value)
87
+ def plays(value)
87
88
  return 0 if value == true
88
89
  return 1 if value == false
89
- return value if value.is_a?(Integer) && value.between?(0, 0xffff_ffff)
90
+ return value if value.is_a?(Integer) && value.between?(0, 0xFFFF_FFFF)
90
91
 
91
92
  raise ArgumentError, "loop must be true, false, or an integer from 0 to 4294967295"
92
93
  end
93
94
 
94
- def write_header
95
- @io << Tessel::SIGNATURE
96
- write_chunk("IHDR", [@width, @height, 8, 6, 0, 0, 0].pack("N2C5"))
97
- @actl_data_position = @io.pos + 8
98
- write_chunk("acTL", [1, @loop].pack("N2"))
99
- end
100
-
101
- def write_frame(image, delay)
102
- rectangle = if @frame_count.zero? || !@optimize
103
- [0, 0, @width, @height]
104
- else
105
- Diff.bounds(@previous_image, image) || [0, 0, 1, 1]
106
- end
107
- x, y, width, height = rectangle
108
- frame = rectangle == [0, 0, @width, @height] ? image : image.crop(x, y, width, height)
109
- numerator, denominator = delay_fraction(delay)
110
- alpha = frame.bytes.bytes.each_slice(4).any? { |_, _, _, channel| channel < 255 }
111
- blend = @frame_count.zero? || alpha ? 0 : 1
112
- control = [@sequence, width, height, x, y].pack("N5") + [numerator, denominator, 0, blend].pack("n2C2")
113
- write_chunk("fcTL", control)
114
- @sequence += 1
115
-
116
- png = Tessel::PNG::Encoder.encode(frame)
117
- idat = png_chunks(png).select { |type, _| type == "IDAT" }.map(&:last).join
118
- if @frame_count.zero?
119
- write_chunk("IDAT", idat)
120
- else
121
- write_chunk("fdAT", [@sequence].pack("N") + idat)
122
- @sequence += 1
123
- end
124
- end
125
-
126
- def delay_fraction(delay)
127
- raise ArgumentError, "APNG frame delay cannot exceed 65535 seconds" if delay > 65_535
128
-
129
- if delay.numerator <= 65_535 && delay.denominator <= 65_535
130
- return [delay.numerator, delay.denominator]
131
- end
132
-
133
- denominator = [65_535, (65_535 / delay).floor].min
134
- denominator = 65_535 if denominator < 1
135
- numerator = (delay * denominator).round.clamp(1, 65_535)
136
- [numerator, denominator]
95
+ def write_chunk(type, data)
96
+ Tessel::PNG::Chunk.write(@storage, type, data)
137
97
  end
138
98
 
139
- def png_chunks(png)
140
- chunks = []
141
- offset = Tessel::SIGNATURE.bytesize
142
- while offset < png.bytesize
143
- length = png.unpack1("N", offset:)
144
- type = png.byteslice(offset + 4, 4)
145
- data = png.byteslice(offset + 8, length)
146
- chunks << [type, data]
147
- offset += length + 12
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
148
108
  break if type == "IEND"
149
109
  end
150
- chunks
151
- end
110
+ raise Error, "Tessel produced a PNG without image data" if output.empty?
152
111
 
153
- def write_chunk(type, data)
154
- Tessel::PNG::Chunk.write(@io, type, data)
112
+ output
155
113
  end
156
114
 
157
- def patch_animation_control
158
- end_position = @io.pos
159
- data = [@frame_count, @loop].pack("N2")
160
- @io.seek(@actl_data_position)
161
- @io.write(data)
162
- @io.write([Zlib.crc32("acTL".b + data)].pack("N"))
163
- @io.seek(end_position)
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)
164
121
  end
165
122
  end
166
123
  end
data/lib/flipbook/diff.rb CHANGED
@@ -4,38 +4,27 @@ module Flipbook
4
4
  module Diff
5
5
  module_function
6
6
 
7
- # Returns the smallest changed rectangle, or nil when the images match.
8
7
  def bounds(previous, current)
9
- unless previous.is_a?(Tessel::Image) && current.is_a?(Tessel::Image)
10
- raise TypeError, "frames must be Tessel::Image values"
11
- end
12
- unless previous.width == current.width && previous.height == current.height
13
- raise ArgumentError, "frame dimensions must match"
14
- end
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
15
12
 
16
- left = current.width
17
- top = current.height
18
- right = -1
19
- bottom = -1
20
- before = previous.bytes
21
- after = current.bytes
22
- current.height.times do |y|
23
- current.width.times do |x|
24
- offset = (y * current.width + x) * 4
25
- next if before.byteslice(offset, 4) == after.byteslice(offset, 4)
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)
26
19
 
27
- left = x if x < left
28
- top = y if y < top
29
- right = x if x > right
30
- bottom = y if y > bottom
31
- end
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
32
26
  end
33
- right.negative? ? nil : [left, top, right - left + 1, bottom - top + 1]
34
- end
35
-
36
- def crop(image, rectangle)
37
- x, y, width, height = rectangle
38
- image.crop(x, y, width, height)
27
+ right.negative? ? [0, 0, 1, 1] : [left, top, right - left + 1, bottom - top + 1]
39
28
  end
40
29
  end
41
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,20 +20,20 @@ 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, optimize: true)
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)
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)
31
30
 
32
31
  @loop = loop_value(loop)
33
32
  @colors = colors
34
33
  @dither = dither
35
- @optimize = optimize
36
- @previous_image = nil
34
+ @clear_background = clear_background
35
+ @optimize = optimize && !clear_background
36
+ @previous = nil
37
37
  @elapsed = Rational(0)
38
38
  @rounded_delay = 0
39
39
  @closed = false
@@ -41,11 +41,17 @@ module Flipbook
41
41
  @frame_count = 0
42
42
  @palette_mode = palette
43
43
  if palette == :per_frame
44
+ raise ArgumentError, "clear_background requires a global palette" if clear_background
44
45
  raise ArgumentError, "transparent_index is only valid with a global palette" unless transparent_index.nil?
45
46
  @global_palette = nil
46
47
  else
47
48
  raise TypeError, "palette must be :per_frame or an RGB array" unless palette.is_a?(Array)
48
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
49
55
  if transparent_index
50
56
  raise ArgumentError, "transparent_index must be the last palette entry" unless transparent_index == @global_palette.length - 1
51
57
  raise ArgumentError, "transparent_index is out of range" unless transparent_index.between?(0, @global_palette.length - 1)
@@ -67,32 +73,41 @@ module Flipbook
67
73
  @warned_short_delay = true
68
74
  end
69
75
  transparent_pixels = image.bytes.bytes.each_slice(4).any? { |_, _, _, alpha| alpha.zero? }
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
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
83
  palette, opaque_palette, transparent_index = frame_palette(image, transparent_pixels)
75
- rectangle = if @optimize && @previous_image && !transparent_pixels
76
- Diff.bounds(@previous_image, image) || [0, 0, 1, 1]
77
- else
78
- [0, 0, @width, @height]
79
- 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)
84
+ indices, = Tessel::Quantize.quantize(image, palette: opaque_palette, dither: @dither)
83
85
  if transparent_pixels
84
86
  raise ArgumentError, "palette has no transparent entry" unless transparent_index
85
- frame.bytes.bytes.each_slice(4).with_index do |(_, _, _, alpha), index|
87
+ image.bytes.bytes.each_slice(4).with_index do |(_, _, _, alpha), index|
86
88
  indices.setbyte(index, transparent_index) if alpha.zero?
87
89
  end
88
90
  end
89
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)
90
104
  write_control(frame_delay, transparent_index)
91
- write_image(left, top, width, height, indices, palette)
105
+ write_image(left, top, width, height, frame_indices, palette)
92
106
  @elapsed = elapsed
93
107
  @rounded_delay = target_delay
94
108
  @frame_count += 1
95
- @previous_image = image.dup
109
+ @previous_transparency = transparent_pixels
110
+ @previous = image.dup
96
111
  self
97
112
  end
98
113
 
@@ -133,7 +148,7 @@ module Flipbook
133
148
  return [@global_palette, opaque, @global_transparent_index]
134
149
  end
135
150
 
136
- reserve = transparent_pixels
151
+ reserve = @optimize || transparent_pixels
137
152
  opaque_count = reserve ? [@colors - 1, 1].max : @colors
138
153
  opaque = Tessel::Quantize.palette_for(image, colors: [opaque_count, 2].max).first(opaque_count)
139
154
  palette = reserve ? opaque + [[0, 0, 0]] : opaque
@@ -144,7 +159,8 @@ module Flipbook
144
159
  @io << "GIF89a".b << [@width, @height].pack("v2")
145
160
  if @global_palette
146
161
  bits, size = table_size(@global_palette.length)
147
- @io << [0x80 | 0x70 | (bits - 1), @global_transparent_index || 0, 0].pack("C3")
162
+ background = @clear_background ? @global_transparent_index : 0
163
+ @io << [0x80 | 0x70 | (bits - 1), background, 0].pack("C3")
148
164
  write_palette(@global_palette, size)
149
165
  else
150
166
  @io << "\0\0\0".b
@@ -155,7 +171,7 @@ module Flipbook
155
171
  end
156
172
 
157
173
  def write_control(delay, transparent_index)
158
- packed = 0
174
+ packed = @clear_background ? 0x08 : (@optimize ? 0x04 : 0)
159
175
  packed |= 0x01 if transparent_index
160
176
  @io << "!\xF9\x04".b << [packed, delay, transparent_index || 0, 0].pack("CvCC")
161
177
  end
@@ -180,6 +196,13 @@ module Flipbook
180
196
  (size - palette.length).times { @io << "\0\0\0".b }
181
197
  end
182
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
183
206
  end
184
207
  end
185
208
  end
@@ -4,7 +4,7 @@ module Flipbook
4
4
  module Timing
5
5
  module_function
6
6
 
7
- def delays(count, fps: nil, delay: nil, warn_short: true)
7
+ def delays(count, fps: nil, delay: nil)
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 warn_short && values.any? { |value| value < Rational(1, 50) }
22
+ warn("Flipbook: delays below 1/50 second may be clamped by GIF viewers") if values.any? { |value| value < Rational(1, 50) }
23
23
  values
24
24
  end
25
25
 
@@ -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.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/flipbook.rb CHANGED
@@ -1,43 +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"
10
13
  require_relative "flipbook/apng/writer"
11
- require_relative "flipbook/diff"
12
14
 
13
15
  module Flipbook
14
16
  class Error < StandardError; end
15
17
 
16
18
  module_function
17
19
 
18
- def write(path, frames, fps: nil, delay: nil, loop: true, colors: 256, palette: :global, dither: :floyd_steinberg, optimize: true)
20
+ def write(path, frames, fps: nil, delay: nil, loop: true, format: nil, colors: 256, palette: :global, dither: :floyd_steinberg, optimize: true, **options)
19
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) }
20
22
  raise ArgumentError, "at least one frame is required" if frames.empty?
21
23
  raise ArgumentError, "frame dimensions must match" unless frames.all? { |frame| frame.width == frames.first.width && frame.height == frames.first.height }
22
24
 
23
- extension = File.extname(String(path)).downcase
24
- raise ArgumentError, "output path must end in .gif, .png, or .apng" unless %w[.gif .png .apng].include?(extension)
25
-
26
- delays = Timing.delays(frames.length, fps:, delay:, warn_short: extension == ".gif")
27
- if extension == ".gif"
25
+ delays = Timing.delays(frames.length, fps:, delay:)
26
+ extension = format&.to_s&.downcase || File.extname(String(path)).delete_prefix(".").downcase
27
+ case extension
28
+ when "gif"
28
29
  write_gif(path, frames, delays, loop:, colors:, palette:, dither:, optimize:)
29
- else
30
- APNG::Writer.open(path, width: frames.first.width, height: frames.first.height, loop:, optimize:) do |writer|
31
- frames.zip(delays).each do |frame, frame_delay|
32
- writer.add(frame, delay: frame_delay)
33
- end
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) }
34
33
  end
34
+ else
35
+ raise ArgumentError, "format must be GIF or APNG"
35
36
  end
36
37
  end
37
38
 
39
+ def read(path)
40
+ bytes = File.binread(path)
41
+ raise Error, "not a GIF file" unless bytes.start_with?("GIF87a", "GIF89a")
42
+
43
+ GIF::Reader.new(bytes).frames
44
+ end
45
+
38
46
  def write_gif(path, frames, delays, loop:, colors:, palette:, dither:, optimize:)
39
47
  has_transparency = frames.any? { |frame| frame.bytes.bytes.each_slice(4).any? { |_, _, _, alpha| alpha.zero? } }
40
- transparent = has_transparency
48
+ transparent = optimize || has_transparency
41
49
  global_palette = if palette == :per_frame
42
50
  raise ArgumentError, "per-frame palettes cannot preserve transparency across frames" if has_transparency
43
51
  :per_frame
@@ -57,10 +65,9 @@ module Flipbook
57
65
  end
58
66
  transparent_index = global_palette.is_a?(Array) && transparent ? global_palette.length - 1 : nil
59
67
 
60
- GIF::Writer.open(path, width: frames.first.width, height: frames.first.height, loop:, palette: global_palette, colors:, dither:, transparent_index:, optimize:) 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|
61
69
  frames.zip(delays).each { |frame, frame_delay| writer.add(frame, delay: frame_delay) }
62
70
  end
63
71
  end
64
72
  private_class_method :write_gif
65
-
66
73
  end
data/sig/flipbook.rbs CHANGED
@@ -1,22 +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, ?optimize: bool) -> 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
- def self.delays: (Integer count, ?fps: Numeric?, ?delay: Numeric | Array[Numeric]?, ?warn_short: bool) -> Array[Rational]
8
+ def self.delays: (Integer count, ?fps: Numeric?, ?delay: Numeric | Array[Numeric]?) -> Array[Rational]
8
9
  def self.centiseconds: (Array[Numeric] delays) -> Array[Integer]
9
- end
10
-
11
- module APNG
12
- class Writer
13
- def self.open: (String path, width: Integer, height: Integer, ?loop: bool | Integer, ?optimize: bool) { (Writer) -> void } -> String
14
- def add: (Tessel::Image image, delay: Numeric) -> Writer
15
- def close: () -> Writer
16
- end
17
- end
18
-
19
- module Diff
20
- def self.bounds: (Tessel::Image previous, Tessel::Image current) -> [Integer, Integer, Integer, Integer]?
10
+ def self.apng_fraction: (Numeric delay) -> [Integer, Integer]
21
11
  end
22
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.2.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,8 +58,7 @@ 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 animated GIF and APNG files from Tessel RGBA images.
62
62
  email:
63
63
  - t.yudai92@gmail.com
64
64
  executables: []
@@ -70,12 +70,12 @@ files:
70
70
  - README.md
71
71
  - Rakefile
72
72
  - docs/verification.md
73
- - examples/generate.rb
74
73
  - lib/flipbook.rb
75
74
  - lib/flipbook/apng/writer.rb
76
75
  - lib/flipbook/diff.rb
77
76
  - lib/flipbook/gif/bit_writer.rb
78
77
  - lib/flipbook/gif/lzw.rb
78
+ - lib/flipbook/gif/reader.rb
79
79
  - lib/flipbook/gif/writer.rb
80
80
  - lib/flipbook/timing.rb
81
81
  - lib/flipbook/version.rb
@@ -86,6 +86,7 @@ licenses:
86
86
  metadata:
87
87
  source_code_uri: https://github.com/rbgfx/flipbook
88
88
  changelog_uri: https://github.com/rbgfx/flipbook/blob/main/CHANGELOG.md
89
+ post_install_message:
89
90
  rdoc_options: []
90
91
  require_paths:
91
92
  - lib
@@ -100,7 +101,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
100
101
  - !ruby/object:Gem::Version
101
102
  version: '0'
102
103
  requirements: []
103
- rubygems_version: 4.0.16
104
+ rubygems_version: 3.4.19
105
+ signing_key:
104
106
  specification_version: 4
105
- summary: Write animated GIF and APNG files in Ruby
107
+ summary: Write and read GIF and APNG animations in Ruby
106
108
  test_files: []
data/examples/generate.rb DELETED
@@ -1,49 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "fileutils"
4
- require "flipbook"
5
-
6
- output = ARGV.fetch(0, "examples/output")
7
- FileUtils.mkdir_p(output)
8
-
9
- frames = 24.times.map do |frame|
10
- image = Tessel::Image.new(160, 120, fill: "#111827")
11
- angle = frame * Math::PI / 12
12
- cos = Math.cos(angle)
13
- sin = Math.sin(angle)
14
- (35...125).each do |y|
15
- (55...105).each do |x|
16
- dx = x - 80
17
- dy = y - 60
18
- next unless (dx * cos + dy * sin).abs <= 17 && (-dx * sin + dy * cos).abs <= 17
19
-
20
- image[x, y] = [56, 189, 248, 255]
21
- end
22
- end
23
- image
24
- end
25
- Flipbook.write(File.join(output, "spinning-square.gif"), frames, fps: 12)
26
-
27
- gradient = Tessel::Image.new(128, 32)
28
- gradient.height.times do |y|
29
- gradient.width.times do |x|
30
- gradient[x, y] = [x * 2, 100, 255 - x * 2, 255]
31
- end
32
- end
33
- Flipbook.write(File.join(output, "gradient.gif"), [gradient], delay: 1)
34
-
35
- transparent = 16.times.map do |frame|
36
- image = Tessel::Image.new(96, 96)
37
- center_x = 48 + (25 * Math.cos(frame * Math::PI / 8)).round
38
- center_y = 48 + (25 * Math.sin(frame * Math::PI / 8)).round
39
- 96.times do |y|
40
- 96.times do |x|
41
- image[x, y] = [251, 113, 133, 255] if (x - center_x)**2 + (y - center_y)**2 <= 14**2
42
- end
43
- end
44
- image
45
- end
46
- Flipbook.write(File.join(output, "transparent.gif"), transparent, fps: 8)
47
- Flipbook.write(File.join(output, "transparent.png"), transparent, fps: 8)
48
-
49
- puts "Wrote GIF and APNG examples to #{output}"