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 +4 -4
- data/CHANGELOG.md +4 -7
- data/README.md +15 -27
- data/docs/verification.md +3 -3
- data/lib/flipbook/apng/writer.rb +56 -99
- data/lib/flipbook/diff.rb +17 -28
- data/lib/flipbook/gif/lzw.rb +66 -0
- data/lib/flipbook/gif/reader.rb +189 -0
- data/lib/flipbook/gif/writer.rb +41 -18
- data/lib/flipbook/timing.rb +13 -2
- data/lib/flipbook/version.rb +1 -1
- data/lib/flipbook.rb +22 -15
- data/sig/flipbook.rbs +4 -14
- metadata +9 -7
- data/examples/generate.rb +0 -49
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ed6c3ae7ae41a6819cbd15890ebe7002b9637c2a2afb51e1f185ac7cf4c4b39c
|
|
4
|
+
data.tar.gz: 59c4fcbf5f6fce4baa22c359a54334d873403bf2ee6904ab483770cca41a5ab1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3047e42608ce4f1d5653fa4573fc2d9c210e5b732557bb65cf2f41f8087aa8f6b81c1a3e895f4cadc3e03c8c83b532bfaa8ea51a47bd557bcf890bd0fb42e949
|
|
7
|
+
data.tar.gz: 44abccb00089ba9fe0d6b16657c36c2967d506a69e923e42d31971260b09461d405f755e3d195f6fd5a50aadea68971686b0ba101217d1b74bf524887183f375
|
data/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [0.
|
|
3
|
+
## [0.3.0] - 2026-09-24
|
|
4
4
|
|
|
5
|
-
- Add
|
|
6
|
-
-
|
|
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">
|
|
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) · [
|
|
11
|
+
[Formats](#formats) · [Installation](#installation) · [API](#api) · [Development](#development)
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
-
Flipbook writes animated
|
|
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 |
|
|
19
|
+
| Format | Color | Transparency | Reading |
|
|
20
20
|
| --- | --- | --- | --- |
|
|
21
|
-
| GIF | Up to 256 palette entries | One transparent palette index | GIF89a
|
|
22
|
-
| APNG
|
|
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.
|
|
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
|
-
|
|
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
|
|
69
|
-
- `GIF::
|
|
70
|
-
- `APNG::Writer#add`
|
|
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,
|
|
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.
|
data/lib/flipbook/apng/writer.rb
CHANGED
|
@@ -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,
|
|
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
|
|
30
|
-
raise
|
|
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 =
|
|
32
|
+
@loop = plays(loop)
|
|
33
33
|
@optimize = optimize
|
|
34
|
-
@
|
|
35
|
-
@
|
|
36
|
-
@
|
|
37
|
-
@
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
65
|
+
write_chunk("fdAT", [@sequence].pack("N") + compressed)
|
|
66
|
+
@sequence += 1
|
|
53
67
|
end
|
|
54
|
-
@
|
|
55
|
-
@
|
|
68
|
+
@frames += 1
|
|
69
|
+
@previous = image.dup
|
|
56
70
|
self
|
|
57
71
|
end
|
|
58
72
|
|
|
59
73
|
def close
|
|
60
|
-
return
|
|
61
|
-
raise Error, "APNG contains no frames" if @
|
|
74
|
+
return if @closed
|
|
75
|
+
raise Error, "APNG contains no frames" if @frames.zero?
|
|
62
76
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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,
|
|
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
|
|
95
|
-
@
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
151
|
-
end
|
|
110
|
+
raise Error, "Tessel produced a PNG without image data" if output.empty?
|
|
152
111
|
|
|
153
|
-
|
|
154
|
-
Tessel::PNG::Chunk.write(@io, type, data)
|
|
112
|
+
output
|
|
155
113
|
end
|
|
156
114
|
|
|
157
|
-
def
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
@
|
|
161
|
-
|
|
162
|
-
@
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
right = -1
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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? ?
|
|
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
|
data/lib/flipbook/gif/lzw.rb
CHANGED
|
@@ -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
|
data/lib/flipbook/gif/writer.rb
CHANGED
|
@@ -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,
|
|
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
|
-
@
|
|
36
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
data/lib/flipbook/timing.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Flipbook
|
|
|
4
4
|
module Timing
|
|
5
5
|
module_function
|
|
6
6
|
|
|
7
|
-
def delays(count, fps: nil, delay: nil
|
|
7
|
+
def delays(count, fps: nil, delay: nil)
|
|
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
|
|
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
|
|
data/lib/flipbook/version.rb
CHANGED
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
APNG::Writer.open(path, width: frames.first.width, height: frames.first.height, loop:, optimize
|
|
31
|
-
frames.zip(delays).each
|
|
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:,
|
|
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]
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
|
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.
|
|
104
|
+
rubygems_version: 3.4.19
|
|
105
|
+
signing_key:
|
|
104
106
|
specification_version: 4
|
|
105
|
-
summary: Write
|
|
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}"
|