flipbook 0.1.0 → 0.2.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: 21a1c656c9c85bbfdc6ace9cc7c09f3cbc8546c4f716d899d9bc72a1ec42e30b
4
+ data.tar.gz: fd9bda09b22c184e7f95933a9a795edfe4739e965eb1d0fc254d7eb35705a14b
5
5
  SHA512:
6
- metadata.gz: b814f5fbb5d1a743bb406e409b82317ddc3506acf7a8abde3f6317d2ffbba5fc1f2f9721e183fa09fb94113915a98deff2c6915983880ccf31da3002d4bf99de
7
- data.tar.gz: 32f5830b2b7a5584d8e2b21af6727a550717e888f4d8b57c0631738695a10d35feba4084a357878794b18ce4233055b8d68f118aa705b449cab7f4f78c123c40
6
+ metadata.gz: fdd1e8f6929f091b6dad1e8de5fc892ae6e17704d53ac564911962a2671c70e3f2864d751860ea66be5908b065a2276256b2a38e1ef6b4cb35db15fe68a6773c
7
+ data.tar.gz: cd3b12ca02ee239a641db540dede16bcc59647087e083a1cf78205d3b01ec2364c961d412dd9426ced032a839366d98c703f8d1b890b9a1e9d93338d02c5a91c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0] - 2026-09-25
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
+
3
8
  ## [0.1.0] - 2026-09-25
4
9
 
5
10
  Initial release
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">Write optimized animated GIF and APNG files from Ruby images.</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,19 +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) · [Development](#development)
11
+ [Formats](#formats) · [Installation](#installation) · [API](#api) · [Examples](#examples) · [Development](#development)
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 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.
16
16
 
17
17
  ## Formats
18
18
 
19
19
  | Format | Colors | Transparency | Encoding |
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 | 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 |
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. 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
25
 
25
26
  ## Installation
26
27
 
@@ -54,12 +55,31 @@ Flipbook::GIF::Writer.open("recording.gif", width: 320, height: 240, palette: :p
54
55
  end
55
56
  ~~~
56
57
 
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.
65
+
57
66
  ### API contracts
58
67
 
59
- - `Flipbook.write(path, frames, fps: or delay:)` requires one or more same-sized `Tessel::Image` objects and a `.gif` output path.
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.
60
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.
61
71
  - 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.
62
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.
82
+
63
83
  GIF format acknowledgement: The Graphics Interchange Format© is the Copyright property of CompuServe Incorporated. GIF® is a Service Mark property of CompuServe Incorporated.
64
84
 
65
85
  ## Development
data/docs/verification.md CHANGED
@@ -1,6 +1,6 @@
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, frame timing, transparent palette entries, cropped frame bounds, APNG sequence numbers, and PNG chunk CRCs.
4
4
 
5
5
  For an independent local check, install ImageMagick or gifsicle and inspect sample output:
6
6
 
@@ -8,6 +8,7 @@ For an independent local check, install ImageMagick or gifsicle and inspect samp
8
8
  identify animation.gif
9
9
  magick animation.gif -coalesce /tmp/flipbook-gif-%02d.png
10
10
  gifsicle --info animation.gif
11
+ magick identify animation.png
11
12
  ~~~
12
13
 
13
14
  These tools are optional and are not runtime dependencies.
@@ -0,0 +1,49 @@
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}"
@@ -0,0 +1,167 @@
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, buffered: nil)
26
+ @io = io
27
+ @width = Integer(width)
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)
31
+
32
+ @loop = loop_count(loop)
33
+ @optimize = optimize
34
+ @buffered = buffered.nil? ? !seekable?(io) : buffered
35
+ @frames = []
36
+ @previous_image = nil
37
+ @frame_count = 0
38
+ @sequence = 0
39
+ @closed = false
40
+ write_header unless @buffered
41
+ end
42
+
43
+ def add(image, delay:)
44
+ raise IOError, "APNG writer is closed" if @closed
45
+ 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
47
+
48
+ seconds = Timing.duration(delay)
49
+ if @buffered
50
+ @frames << [image.dup, seconds]
51
+ else
52
+ write_frame(image, seconds)
53
+ end
54
+ @frame_count += 1
55
+ @previous_image = image.dup
56
+ self
57
+ end
58
+
59
+ def close
60
+ return self if @closed
61
+ raise Error, "APNG contains no frames" if @frame_count.zero?
62
+
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
73
+ @closed = true
74
+ @io.close if @owns_io && !@io.closed?
75
+ self
76
+ end
77
+
78
+ private
79
+
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
+ return 0 if value == true
88
+ return 1 if value == false
89
+ return value if value.is_a?(Integer) && value.between?(0, 0xffff_ffff)
90
+
91
+ raise ArgumentError, "loop must be true, false, or an integer from 0 to 4294967295"
92
+ end
93
+
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]
137
+ end
138
+
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
148
+ break if type == "IEND"
149
+ end
150
+ chunks
151
+ end
152
+
153
+ def write_chunk(type, data)
154
+ Tessel::PNG::Chunk.write(@io, type, data)
155
+ end
156
+
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)
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flipbook
4
+ module Diff
5
+ module_function
6
+
7
+ # Returns the smallest changed rectangle, or nil when the images match.
8
+ 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
15
+
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)
26
+
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
32
+ 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)
39
+ end
40
+ end
41
+ end
@@ -20,17 +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)
23
+ def initialize(io, width:, height:, loop: true, palette: :per_frame, colors: 256, dither: :floyd_steinberg, transparent_index: nil, optimize: true)
24
24
  @io = io
25
25
  @width = Integer(width)
26
26
  @height = Integer(height)
27
27
  raise ArgumentError, "GIF dimensions must be between 1 and 65535" unless @width.between?(1, 65_535) && @height.between?(1, 65_535)
28
28
  raise ArgumentError, "colors must be between 2 and 256" unless colors.is_a?(Integer) && colors.between?(2, 256)
29
29
  raise ArgumentError, "unknown dither: #{dither}" unless %i[none ordered floyd_steinberg].include?(dither)
30
+ raise TypeError, "optimize must be true or false" unless [true, false].include?(optimize)
30
31
 
31
32
  @loop = loop_value(loop)
32
33
  @colors = colors
33
34
  @dither = dither
35
+ @optimize = optimize
36
+ @previous_image = nil
34
37
  @elapsed = Rational(0)
35
38
  @rounded_delay = 0
36
39
  @closed = false
@@ -64,25 +67,32 @@ module Flipbook
64
67
  @warned_short_delay = true
65
68
  end
66
69
  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
68
70
  elapsed = @elapsed + seconds
69
71
  target_delay = (elapsed * 100).floor
70
72
  frame_delay = target_delay - @rounded_delay
71
73
  raise ArgumentError, "GIF frame delays cannot exceed 655.35 seconds" if frame_delay > 65_535
72
74
  palette, opaque_palette, transparent_index = frame_palette(image, transparent_pixels)
73
- indices, = Tessel::Quantize.quantize(image, palette: opaque_palette, dither: @dither)
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)
74
83
  if transparent_pixels
75
84
  raise ArgumentError, "palette has no transparent entry" unless transparent_index
76
- image.bytes.bytes.each_slice(4).with_index do |(_, _, _, alpha), index|
85
+ frame.bytes.bytes.each_slice(4).with_index do |(_, _, _, alpha), index|
77
86
  indices.setbyte(index, transparent_index) if alpha.zero?
78
87
  end
79
88
  end
80
89
 
81
90
  write_control(frame_delay, transparent_index)
82
- write_image(0, 0, @width, @height, indices, palette)
91
+ write_image(left, top, width, height, indices, palette)
83
92
  @elapsed = elapsed
84
93
  @rounded_delay = target_delay
85
94
  @frame_count += 1
95
+ @previous_image = image.dup
86
96
  self
87
97
  end
88
98
 
@@ -134,7 +144,7 @@ module Flipbook
134
144
  @io << "GIF89a".b << [@width, @height].pack("v2")
135
145
  if @global_palette
136
146
  bits, size = table_size(@global_palette.length)
137
- @io << [0x80 | 0x70 | (bits - 1), 0, 0].pack("C3")
147
+ @io << [0x80 | 0x70 | (bits - 1), @global_transparent_index || 0, 0].pack("C3")
138
148
  write_palette(@global_palette, size)
139
149
  else
140
150
  @io << "\0\0\0".b
@@ -4,7 +4,7 @@ module Flipbook
4
4
  module Timing
5
5
  module_function
6
6
 
7
- def delays(count, fps: nil, delay: nil)
7
+ def delays(count, fps: nil, delay: nil, warn_short: true)
8
8
  raise ArgumentError, "frame count must be a positive integer" unless count.is_a?(Integer) && count.positive?
9
9
  raise ArgumentError, "provide fps or delay, not both" if !fps.nil? && !delay.nil?
10
10
  raise ArgumentError, "fps or delay is required" if fps.nil? && delay.nil?
@@ -19,7 +19,7 @@ module Flipbook
19
19
  else
20
20
  Array.new(count) { positive_delay(delay) }
21
21
  end
22
- warn("Flipbook: delays below 1/50 second may be clamped by GIF viewers") if values.any? { |value| value < Rational(1, 50) }
22
+ warn("Flipbook: delays below 1/50 second may be clamped by GIF viewers") if warn_short && values.any? { |value| value < Rational(1, 50) }
23
23
  values
24
24
  end
25
25
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Flipbook
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/flipbook.rb CHANGED
@@ -7,24 +7,35 @@ require_relative "flipbook/timing"
7
7
  require_relative "flipbook/gif/bit_writer"
8
8
  require_relative "flipbook/gif/lzw"
9
9
  require_relative "flipbook/gif/writer"
10
+ require_relative "flipbook/apng/writer"
11
+ require_relative "flipbook/diff"
10
12
 
11
13
  module Flipbook
12
14
  class Error < StandardError; end
13
15
 
14
16
  module_function
15
17
 
16
- def write(path, frames, fps: nil, delay: nil, loop: true, colors: 256, palette: :global, dither: :floyd_steinberg)
18
+ def write(path, frames, fps: nil, delay: nil, loop: true, colors: 256, palette: :global, dither: :floyd_steinberg, optimize: true)
17
19
  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
20
  raise ArgumentError, "at least one frame is required" if frames.empty?
19
21
  raise ArgumentError, "frame dimensions must match" unless frames.all? { |frame| frame.width == frames.first.width && frame.height == frames.first.height }
20
22
 
21
- delays = Timing.delays(frames.length, fps:, delay:)
22
- raise ArgumentError, "output path must end in .gif" unless File.extname(String(path)).casecmp?(".gif")
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)
23
25
 
24
- write_gif(path, frames, delays, loop:, colors:, palette:, dither:)
26
+ delays = Timing.delays(frames.length, fps:, delay:, warn_short: extension == ".gif")
27
+ if extension == ".gif"
28
+ 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
34
+ end
35
+ end
25
36
  end
26
37
 
27
- def write_gif(path, frames, delays, loop:, colors:, palette:, dither:)
38
+ def write_gif(path, frames, delays, loop:, colors:, palette:, dither:, optimize:)
28
39
  has_transparency = frames.any? { |frame| frame.bytes.bytes.each_slice(4).any? { |_, _, _, alpha| alpha.zero? } }
29
40
  transparent = has_transparency
30
41
  global_palette = if palette == :per_frame
@@ -46,9 +57,10 @@ module Flipbook
46
57
  end
47
58
  transparent_index = global_palette.is_a?(Array) && transparent ? global_palette.length - 1 : nil
48
59
 
49
- GIF::Writer.open(path, width: frames.first.width, height: frames.first.height, loop:, palette: global_palette, colors:, dither:, transparent_index:) do |writer|
60
+ GIF::Writer.open(path, width: frames.first.width, height: frames.first.height, loop:, palette: global_palette, colors:, dither:, transparent_index:, optimize:) do |writer|
50
61
  frames.zip(delays).each { |frame, frame_delay| writer.add(frame, delay: frame_delay) }
51
62
  end
52
63
  end
53
64
  private_class_method :write_gif
65
+
54
66
  end
data/sig/flipbook.rbs CHANGED
@@ -1,10 +1,22 @@
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, ?colors: Integer, ?palette: Symbol | Array[[Integer, Integer, Integer]], ?dither: Symbol, ?optimize: bool) -> String
5
5
 
6
6
  module Timing
7
- def self.delays: (Integer count, ?fps: Numeric?, ?delay: Numeric | Array[Numeric]?) -> Array[Rational]
7
+ def self.delays: (Integer count, ?fps: Numeric?, ?delay: Numeric | Array[Numeric]?, ?warn_short: bool) -> Array[Rational]
8
8
  def self.centiseconds: (Array[Numeric] delays) -> Array[Integer]
9
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]?
21
+ end
10
22
  end
metadata CHANGED
@@ -1,7 +1,7 @@
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.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
@@ -57,7 +57,8 @@ dependencies:
57
57
  - - "~>"
58
58
  - !ruby/object:Gem::Version
59
59
  version: '3.6'
60
- description: Flipbook encodes animated GIF files from Tessel RGBA images.
60
+ description: Flipbook encodes optimized animated GIF and APNG files from Tessel RGBA
61
+ images.
61
62
  email:
62
63
  - t.yudai92@gmail.com
63
64
  executables: []
@@ -69,7 +70,10 @@ files:
69
70
  - README.md
70
71
  - Rakefile
71
72
  - docs/verification.md
73
+ - examples/generate.rb
72
74
  - lib/flipbook.rb
75
+ - lib/flipbook/apng/writer.rb
76
+ - lib/flipbook/diff.rb
73
77
  - lib/flipbook/gif/bit_writer.rb
74
78
  - lib/flipbook/gif/lzw.rb
75
79
  - lib/flipbook/gif/writer.rb
@@ -98,5 +102,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
98
102
  requirements: []
99
103
  rubygems_version: 4.0.16
100
104
  specification_version: 4
101
- summary: Write animated GIFs in Ruby
105
+ summary: Write animated GIF and APNG files in Ruby
102
106
  test_files: []