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 +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +26 -6
- data/docs/verification.md +2 -1
- data/examples/generate.rb +49 -0
- data/lib/flipbook/apng/writer.rb +167 -0
- data/lib/flipbook/diff.rb +41 -0
- data/lib/flipbook/gif/writer.rb +16 -6
- data/lib/flipbook/timing.rb +2 -2
- data/lib/flipbook/version.rb +1 -1
- data/lib/flipbook.rb +18 -6
- data/sig/flipbook.rbs +14 -2
- metadata +7 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 21a1c656c9c85bbfdc6ace9cc7c09f3cbc8546c4f716d899d9bc72a1ec42e30b
|
|
4
|
+
data.tar.gz: fd9bda09b22c184e7f95933a9a795edfe4739e965eb1d0fc254d7eb35705a14b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fdd1e8f6929f091b6dad1e8de5fc892ae6e17704d53ac564911962a2671c70e3f2864d751860ea66be5908b065a2276256b2a38e1ef6b4cb35db15fe68a6773c
|
|
7
|
+
data.tar.gz: cd3b12ca02ee239a641db540dede16bcc59647087e083a1cf78205d3b01ec2364c961d412dd9426ced032a839366d98c703f8d1b890b9a1e9d93338d02c5a91c
|
data/CHANGELOG.md
CHANGED
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">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
|
|
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,
|
|
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
|
data/lib/flipbook/gif/writer.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
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, 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
|
|
data/lib/flipbook/version.rb
CHANGED
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
|
-
|
|
22
|
-
raise ArgumentError, "output path must end in .gif" unless
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
105
|
+
summary: Write animated GIF and APNG files in Ruby
|
|
102
106
|
test_files: []
|