flipbook 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -2
- data/README.md +15 -7
- data/docs/verification.md +3 -2
- data/lib/flipbook/apng/writer.rb +124 -0
- data/lib/flipbook/diff.rb +30 -0
- data/lib/flipbook/gif/lzw.rb +66 -0
- data/lib/flipbook/gif/reader.rb +189 -0
- data/lib/flipbook/gif/writer.rb +39 -6
- data/lib/flipbook/timing.rb +11 -0
- data/lib/flipbook/version.rb +1 -1
- data/lib/flipbook.rb +25 -6
- data/sig/flipbook.rbs +3 -1
- metadata +11 -5
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,5 +1,7 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [0.
|
|
3
|
+
## [0.3.0] - 2026-09-24
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- Add GIF89a encoding, animated PNG encoding, and GIF decoding.
|
|
6
|
+
- Add streaming writers, cumulative frame timing, palette quantization, transparency, and frame-difference output.
|
|
7
|
+
- Add the `Flipbook.write` and `Flipbook.read` APIs.
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<h1 align="center">Flipbook</h1>
|
|
2
2
|
|
|
3
|
-
<p align="center">Turn a sequence of RGBA images into animated GIF files.</p>
|
|
3
|
+
<p align="center">Turn a sequence of RGBA images into animated GIF or APNG files.</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
6
|
<a href="https://rubygems.org/gems/flipbook"><img src="https://badge.fury.io/rb/flipbook.svg" alt="Gem version"></a>
|
|
@@ -12,15 +12,16 @@
|
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
-
Flipbook writes animated
|
|
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
|
|
21
|
+
| GIF | Up to 256 palette entries | One transparent palette index | GIF87a/GIF89a frames, local and global palettes, interlace, and disposal methods |
|
|
22
|
+
| APNG | RGBA8 | Full alpha | First frame through Tessel's PNG reader |
|
|
22
23
|
|
|
23
|
-
GIF uses a single global palette by default so colors stay stable across frames.
|
|
24
|
+
GIF uses a single global palette by default so colors stay stable across frames. Animated frames with transparent pixels are written as full frames with background disposal so pixels can become transparent correctly. Opaque animations use difference rectangles by default.
|
|
24
25
|
|
|
25
26
|
## Installation
|
|
26
27
|
|
|
@@ -42,6 +43,9 @@ frames = 60.times.map do |index|
|
|
|
42
43
|
end
|
|
43
44
|
|
|
44
45
|
Flipbook.write("animation.gif", frames, fps: 30, loop: true)
|
|
46
|
+
Flipbook.write("animation.png", frames, fps: 30, loop: true)
|
|
47
|
+
|
|
48
|
+
decoded_frames = Flipbook.read("animation.gif")
|
|
45
49
|
~~~
|
|
46
50
|
|
|
47
51
|
Use `delay:` in seconds instead of `fps:`. It accepts one duration or one duration per frame; `Rational(1, 30)` avoids floating-point timing drift. GIF delays use cumulative centisecond rounding. Viewers may lengthen delays below 1/50 second.
|
|
@@ -54,11 +58,15 @@ Flipbook::GIF::Writer.open("recording.gif", width: 320, height: 240, palette: :p
|
|
|
54
58
|
end
|
|
55
59
|
~~~
|
|
56
60
|
|
|
61
|
+
APNG also supports streaming output and patches its frame count when the destination can seek. Otherwise it buffers the animation until close.
|
|
62
|
+
|
|
57
63
|
### API contracts
|
|
58
64
|
|
|
59
|
-
- `Flipbook.write(path, frames, fps: or delay:)` requires one or more same-sized `Tessel::Image` objects
|
|
60
|
-
- `GIF::
|
|
65
|
+
- `Flipbook.write(path, frames, fps: or delay:)` requires one or more same-sized `Tessel::Image` objects. The `.gif` extension selects GIF; `.png` and `.apng` select APNG.
|
|
66
|
+
- `Flipbook.read(path)` returns composited GIF frames as `Tessel::Image` objects. `Flipbook::GIF::Reader#delays` returns each frame delay as a `Rational` in seconds.
|
|
67
|
+
- `GIF::Writer#add` and `APNG::Writer#add` raise `TypeError` for non-image frames and `ArgumentError` for invalid dimensions or timing.
|
|
61
68
|
- GIF palette colors are RGB triples of integer channels from 0 to 255. GIF transparency is binary; partial alpha is encoded as an opaque palette color.
|
|
69
|
+
- APNG chunks use RGBA8 pixels. The first frame is also a valid static PNG image.
|
|
62
70
|
|
|
63
71
|
GIF format acknowledgement: The Graphics Interchange Format© is the Copyright property of CompuServe Incorporated. GIF® is a Service Mark property of CompuServe Incorporated.
|
|
64
72
|
|
data/docs/verification.md
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# File verification
|
|
2
2
|
|
|
3
|
-
The test suite checks GIF LZW code-width growth and dictionary resets,
|
|
3
|
+
The test suite checks GIF LZW code-width growth and dictionary resets, decodes generated animations, and validates APNG chunk ordering and sequence numbers.
|
|
4
4
|
|
|
5
5
|
For an independent local check, install ImageMagick or gifsicle and inspect sample output:
|
|
6
6
|
|
|
7
7
|
~~~sh
|
|
8
|
-
identify animation.gif
|
|
8
|
+
identify animation.gif animation.png
|
|
9
9
|
magick animation.gif -coalesce /tmp/flipbook-gif-%02d.png
|
|
10
|
+
magick animation.png -coalesce /tmp/flipbook-apng-%02d.png
|
|
10
11
|
gifsicle --info animation.gif
|
|
11
12
|
~~~
|
|
12
13
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module Flipbook
|
|
6
|
+
module APNG
|
|
7
|
+
class Writer
|
|
8
|
+
def self.open(path, **options)
|
|
9
|
+
io = File.open(path, "wb+")
|
|
10
|
+
writer = new(io, **options)
|
|
11
|
+
writer.instance_variable_set(:@owns_io, true)
|
|
12
|
+
return writer unless block_given?
|
|
13
|
+
|
|
14
|
+
begin
|
|
15
|
+
yield writer
|
|
16
|
+
ensure
|
|
17
|
+
writer.close
|
|
18
|
+
end
|
|
19
|
+
path
|
|
20
|
+
rescue StandardError
|
|
21
|
+
io&.close unless io&.closed?
|
|
22
|
+
raise
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(io, width:, height:, loop: true, optimize: true, level: Zlib::DEFAULT_COMPRESSION, filter: :none)
|
|
26
|
+
@io = io
|
|
27
|
+
@width = Integer(width)
|
|
28
|
+
@height = Integer(height)
|
|
29
|
+
raise ArgumentError, "APNG dimensions must be positive" unless @width.positive? && @height.positive?
|
|
30
|
+
raise ArgumentError, "unknown PNG filter: #{filter}" unless %i[none sub up average paeth adaptive].include?(filter)
|
|
31
|
+
|
|
32
|
+
@loop = plays(loop)
|
|
33
|
+
@optimize = optimize
|
|
34
|
+
@level = level
|
|
35
|
+
@filter = filter
|
|
36
|
+
@seekable = io.respond_to?(:pos) && io.respond_to?(:seek)
|
|
37
|
+
@storage = @seekable ? io : StringIO.new("".b)
|
|
38
|
+
@frames = 0
|
|
39
|
+
@sequence = 0
|
|
40
|
+
@previous = nil
|
|
41
|
+
@closed = false
|
|
42
|
+
@storage << Tessel::SIGNATURE
|
|
43
|
+
Tessel::PNG::Chunk.write(@storage, "IHDR", [@width, @height, 8, 6, 0, 0, 0].pack("N2C5"))
|
|
44
|
+
@animation_offset = @storage.pos
|
|
45
|
+
write_chunk("acTL", [0, @loop].pack("N2"))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def add(image, delay:)
|
|
49
|
+
raise IOError, "APNG writer is closed" if @closed
|
|
50
|
+
raise TypeError, "frame must be a Tessel::Image" unless image.is_a?(Tessel::Image)
|
|
51
|
+
raise ArgumentError, "frame dimensions must match the animation" unless image.width == @width && image.height == @height
|
|
52
|
+
|
|
53
|
+
seconds = Timing.duration(delay)
|
|
54
|
+
left, top, width, height = @optimize ? Diff.bounds(@previous, image) : [0, 0, @width, @height]
|
|
55
|
+
frame = image.crop(left, top, width, height)
|
|
56
|
+
numerator, denominator = Timing.apng_fraction(seconds)
|
|
57
|
+
compressed = idat_data(frame)
|
|
58
|
+
raise Error, "APNG frame count exceeds the format limit" if @frames >= 0xFFFF_FFFF
|
|
59
|
+
control = [@sequence, width, height, left, top, numerator, denominator, 0, 0].pack("N5n2C2")
|
|
60
|
+
write_chunk("fcTL", control)
|
|
61
|
+
@sequence += 1
|
|
62
|
+
if @frames.zero?
|
|
63
|
+
write_chunk("IDAT", compressed)
|
|
64
|
+
else
|
|
65
|
+
write_chunk("fdAT", [@sequence].pack("N") + compressed)
|
|
66
|
+
@sequence += 1
|
|
67
|
+
end
|
|
68
|
+
@frames += 1
|
|
69
|
+
@previous = image.dup
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def close
|
|
74
|
+
return if @closed
|
|
75
|
+
raise Error, "APNG contains no frames" if @frames.zero?
|
|
76
|
+
|
|
77
|
+
write_chunk("IEND", "".b)
|
|
78
|
+
finish_animation_control
|
|
79
|
+
@io << @storage.string if @storage != @io
|
|
80
|
+
@closed = true
|
|
81
|
+
@io.close if @owns_io && !@io.closed?
|
|
82
|
+
self
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def plays(value)
|
|
88
|
+
return 0 if value == true
|
|
89
|
+
return 1 if value == false
|
|
90
|
+
return value if value.is_a?(Integer) && value.between?(0, 0xFFFF_FFFF)
|
|
91
|
+
|
|
92
|
+
raise ArgumentError, "loop must be true, false, or an integer from 0 to 4294967295"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def write_chunk(type, data)
|
|
96
|
+
Tessel::PNG::Chunk.write(@storage, type, data)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def idat_data(image)
|
|
100
|
+
png = Tessel::PNG.encode(image, color_type: :rgba, filter: @filter, level: @level, metadata: {})
|
|
101
|
+
output = "".b
|
|
102
|
+
cursor = Tessel::SIGNATURE.bytesize
|
|
103
|
+
while cursor + 12 <= png.bytesize
|
|
104
|
+
length = png.byteslice(cursor, 4).unpack1("N")
|
|
105
|
+
type = png.byteslice(cursor + 4, 4)
|
|
106
|
+
output << png.byteslice(cursor + 8, length) if type == "IDAT"
|
|
107
|
+
cursor += length + 12
|
|
108
|
+
break if type == "IEND"
|
|
109
|
+
end
|
|
110
|
+
raise Error, "Tessel produced a PNG without image data" if output.empty?
|
|
111
|
+
|
|
112
|
+
output
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def finish_animation_control
|
|
116
|
+
control = [@frames, @loop].pack("N2")
|
|
117
|
+
ending = @storage.pos
|
|
118
|
+
@storage.seek(@animation_offset)
|
|
119
|
+
write_chunk("acTL", control)
|
|
120
|
+
@storage.seek(ending)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flipbook
|
|
4
|
+
module Diff
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def bounds(previous, current)
|
|
8
|
+
raise TypeError, "current must be a Tessel::Image" unless current.is_a?(Tessel::Image)
|
|
9
|
+
return [0, 0, current.width, current.height] unless previous
|
|
10
|
+
raise TypeError, "previous must be a Tessel::Image" unless previous.is_a?(Tessel::Image)
|
|
11
|
+
raise ArgumentError, "image dimensions must match" unless previous.width == current.width && previous.height == current.height
|
|
12
|
+
|
|
13
|
+
old_bytes = previous.bytes
|
|
14
|
+
new_bytes = current.bytes
|
|
15
|
+
left, top, right, bottom = current.width, current.height, -1, -1
|
|
16
|
+
(0...(current.width * current.height)).each do |index|
|
|
17
|
+
offset = index * 4
|
|
18
|
+
next if old_bytes.byteslice(offset, 4) == new_bytes.byteslice(offset, 4)
|
|
19
|
+
|
|
20
|
+
x = index % current.width
|
|
21
|
+
y = index / current.width
|
|
22
|
+
left = x if x < left
|
|
23
|
+
top = y if y < top
|
|
24
|
+
right = x if x > right
|
|
25
|
+
bottom = y if y > bottom
|
|
26
|
+
end
|
|
27
|
+
right.negative? ? [0, 0, 1, 1] : [left, top, right - left + 1, bottom - top + 1]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
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,7 +20,7 @@ module Flipbook
|
|
|
20
20
|
raise
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
def initialize(io, width:, height:, loop: true, palette: :per_frame, colors: 256, dither: :floyd_steinberg, transparent_index: nil)
|
|
23
|
+
def initialize(io, width:, height:, loop: true, palette: :per_frame, colors: 256, dither: :floyd_steinberg, optimize: true, transparent_index: nil, clear_background: false)
|
|
24
24
|
@io = io
|
|
25
25
|
@width = Integer(width)
|
|
26
26
|
@height = Integer(height)
|
|
@@ -31,6 +31,9 @@ module Flipbook
|
|
|
31
31
|
@loop = loop_value(loop)
|
|
32
32
|
@colors = colors
|
|
33
33
|
@dither = dither
|
|
34
|
+
@clear_background = clear_background
|
|
35
|
+
@optimize = optimize && !clear_background
|
|
36
|
+
@previous = nil
|
|
34
37
|
@elapsed = Rational(0)
|
|
35
38
|
@rounded_delay = 0
|
|
36
39
|
@closed = false
|
|
@@ -38,11 +41,17 @@ module Flipbook
|
|
|
38
41
|
@frame_count = 0
|
|
39
42
|
@palette_mode = palette
|
|
40
43
|
if palette == :per_frame
|
|
44
|
+
raise ArgumentError, "clear_background requires a global palette" if clear_background
|
|
41
45
|
raise ArgumentError, "transparent_index is only valid with a global palette" unless transparent_index.nil?
|
|
42
46
|
@global_palette = nil
|
|
43
47
|
else
|
|
44
48
|
raise TypeError, "palette must be :per_frame or an RGB array" unless palette.is_a?(Array)
|
|
45
49
|
@global_palette = validate_palette(palette)
|
|
50
|
+
if (@optimize || clear_background) && transparent_index.nil?
|
|
51
|
+
raise ArgumentError, "global palettes need room for a transparent color when optimize is enabled" if @global_palette.length == 256
|
|
52
|
+
@global_palette = @global_palette + [[0, 0, 0]]
|
|
53
|
+
transparent_index = @global_palette.length - 1
|
|
54
|
+
end
|
|
46
55
|
if transparent_index
|
|
47
56
|
raise ArgumentError, "transparent_index must be the last palette entry" unless transparent_index == @global_palette.length - 1
|
|
48
57
|
raise ArgumentError, "transparent_index is out of range" unless transparent_index.between?(0, @global_palette.length - 1)
|
|
@@ -64,7 +73,9 @@ module Flipbook
|
|
|
64
73
|
@warned_short_delay = true
|
|
65
74
|
end
|
|
66
75
|
transparent_pixels = image.bytes.bytes.each_slice(4).any? { |_, _, _, alpha| alpha.zero? }
|
|
67
|
-
|
|
76
|
+
if @palette_mode == :per_frame && @frame_count.positive? && (@previous_transparency || transparent_pixels)
|
|
77
|
+
raise ArgumentError, "transparent multi-frame GIFs require a global palette"
|
|
78
|
+
end
|
|
68
79
|
elapsed = @elapsed + seconds
|
|
69
80
|
target_delay = (elapsed * 100).floor
|
|
70
81
|
frame_delay = target_delay - @rounded_delay
|
|
@@ -78,11 +89,25 @@ module Flipbook
|
|
|
78
89
|
end
|
|
79
90
|
end
|
|
80
91
|
|
|
92
|
+
left, top, width, height = @optimize ? Diff.bounds(@previous, image) : [0, 0, @width, @height]
|
|
93
|
+
if @previous && @optimize && transparent_index
|
|
94
|
+
image_bytes = image.bytes
|
|
95
|
+
old_bytes = @previous.bytes
|
|
96
|
+
(top...(top + height)).each do |y|
|
|
97
|
+
(left...(left + width)).each do |x|
|
|
98
|
+
offset = (y * @width + x) * 4
|
|
99
|
+
indices.setbyte(y * @width + x, transparent_index) if image_bytes.byteslice(offset, 4) == old_bytes.byteslice(offset, 4)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
frame_indices = crop_indices(indices, left, top, width, height)
|
|
81
104
|
write_control(frame_delay, transparent_index)
|
|
82
|
-
write_image(
|
|
105
|
+
write_image(left, top, width, height, frame_indices, palette)
|
|
83
106
|
@elapsed = elapsed
|
|
84
107
|
@rounded_delay = target_delay
|
|
85
108
|
@frame_count += 1
|
|
109
|
+
@previous_transparency = transparent_pixels
|
|
110
|
+
@previous = image.dup
|
|
86
111
|
self
|
|
87
112
|
end
|
|
88
113
|
|
|
@@ -123,7 +148,7 @@ module Flipbook
|
|
|
123
148
|
return [@global_palette, opaque, @global_transparent_index]
|
|
124
149
|
end
|
|
125
150
|
|
|
126
|
-
reserve = transparent_pixels
|
|
151
|
+
reserve = @optimize || transparent_pixels
|
|
127
152
|
opaque_count = reserve ? [@colors - 1, 1].max : @colors
|
|
128
153
|
opaque = Tessel::Quantize.palette_for(image, colors: [opaque_count, 2].max).first(opaque_count)
|
|
129
154
|
palette = reserve ? opaque + [[0, 0, 0]] : opaque
|
|
@@ -134,7 +159,8 @@ module Flipbook
|
|
|
134
159
|
@io << "GIF89a".b << [@width, @height].pack("v2")
|
|
135
160
|
if @global_palette
|
|
136
161
|
bits, size = table_size(@global_palette.length)
|
|
137
|
-
|
|
162
|
+
background = @clear_background ? @global_transparent_index : 0
|
|
163
|
+
@io << [0x80 | 0x70 | (bits - 1), background, 0].pack("C3")
|
|
138
164
|
write_palette(@global_palette, size)
|
|
139
165
|
else
|
|
140
166
|
@io << "\0\0\0".b
|
|
@@ -145,7 +171,7 @@ module Flipbook
|
|
|
145
171
|
end
|
|
146
172
|
|
|
147
173
|
def write_control(delay, transparent_index)
|
|
148
|
-
packed = 0
|
|
174
|
+
packed = @clear_background ? 0x08 : (@optimize ? 0x04 : 0)
|
|
149
175
|
packed |= 0x01 if transparent_index
|
|
150
176
|
@io << "!\xF9\x04".b << [packed, delay, transparent_index || 0, 0].pack("CvCC")
|
|
151
177
|
end
|
|
@@ -170,6 +196,13 @@ module Flipbook
|
|
|
170
196
|
(size - palette.length).times { @io << "\0\0\0".b }
|
|
171
197
|
end
|
|
172
198
|
|
|
199
|
+
def crop_indices(indices, left, top, width, height)
|
|
200
|
+
output = String.new(capacity: width * height, encoding: Encoding::BINARY)
|
|
201
|
+
height.times do |row|
|
|
202
|
+
output << indices.byteslice((top + row) * @width + left, width)
|
|
203
|
+
end
|
|
204
|
+
output
|
|
205
|
+
end
|
|
173
206
|
end
|
|
174
207
|
end
|
|
175
208
|
end
|
data/lib/flipbook/timing.rb
CHANGED
|
@@ -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,32 +1,51 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "tessel"
|
|
4
|
+
require "zlib"
|
|
4
5
|
|
|
5
6
|
require_relative "flipbook/version"
|
|
6
7
|
require_relative "flipbook/timing"
|
|
8
|
+
require_relative "flipbook/diff"
|
|
7
9
|
require_relative "flipbook/gif/bit_writer"
|
|
8
10
|
require_relative "flipbook/gif/lzw"
|
|
9
11
|
require_relative "flipbook/gif/writer"
|
|
12
|
+
require_relative "flipbook/gif/reader"
|
|
13
|
+
require_relative "flipbook/apng/writer"
|
|
10
14
|
|
|
11
15
|
module Flipbook
|
|
12
16
|
class Error < StandardError; end
|
|
13
17
|
|
|
14
18
|
module_function
|
|
15
19
|
|
|
16
|
-
def write(path, frames, fps: nil, delay: nil, loop: true, colors: 256, palette: :global, dither: :floyd_steinberg)
|
|
20
|
+
def write(path, frames, fps: nil, delay: nil, loop: true, format: nil, colors: 256, palette: :global, dither: :floyd_steinberg, optimize: true, **options)
|
|
17
21
|
raise TypeError, "frames must be an array of Tessel::Image values" unless frames.is_a?(Array) && frames.all? { |frame| frame.is_a?(Tessel::Image) }
|
|
18
22
|
raise ArgumentError, "at least one frame is required" if frames.empty?
|
|
19
23
|
raise ArgumentError, "frame dimensions must match" unless frames.all? { |frame| frame.width == frames.first.width && frame.height == frames.first.height }
|
|
20
24
|
|
|
21
25
|
delays = Timing.delays(frames.length, fps:, delay:)
|
|
22
|
-
|
|
26
|
+
extension = format&.to_s&.downcase || File.extname(String(path)).delete_prefix(".").downcase
|
|
27
|
+
case extension
|
|
28
|
+
when "gif"
|
|
29
|
+
write_gif(path, frames, delays, loop:, colors:, palette:, dither:, optimize:)
|
|
30
|
+
when "png", "apng"
|
|
31
|
+
APNG::Writer.open(path, width: frames.first.width, height: frames.first.height, loop:, optimize:, **options) do |writer|
|
|
32
|
+
frames.zip(delays).each { |frame, frame_delay| writer.add(frame, delay: frame_delay) }
|
|
33
|
+
end
|
|
34
|
+
else
|
|
35
|
+
raise ArgumentError, "format must be GIF or APNG"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def read(path)
|
|
40
|
+
bytes = File.binread(path)
|
|
41
|
+
raise Error, "not a GIF file" unless bytes.start_with?("GIF87a", "GIF89a")
|
|
23
42
|
|
|
24
|
-
|
|
43
|
+
GIF::Reader.new(bytes).frames
|
|
25
44
|
end
|
|
26
45
|
|
|
27
|
-
def write_gif(path, frames, delays, loop:, colors:, palette:, dither:)
|
|
46
|
+
def write_gif(path, frames, delays, loop:, colors:, palette:, dither:, optimize:)
|
|
28
47
|
has_transparency = frames.any? { |frame| frame.bytes.bytes.each_slice(4).any? { |_, _, _, alpha| alpha.zero? } }
|
|
29
|
-
transparent = has_transparency
|
|
48
|
+
transparent = optimize || has_transparency
|
|
30
49
|
global_palette = if palette == :per_frame
|
|
31
50
|
raise ArgumentError, "per-frame palettes cannot preserve transparency across frames" if has_transparency
|
|
32
51
|
:per_frame
|
|
@@ -46,7 +65,7 @@ module Flipbook
|
|
|
46
65
|
end
|
|
47
66
|
transparent_index = global_palette.is_a?(Array) && transparent ? global_palette.length - 1 : nil
|
|
48
67
|
|
|
49
|
-
GIF::Writer.open(path, width: frames.first.width, height: frames.first.height, loop:, palette: global_palette, colors:, dither:, transparent_index:) do |writer|
|
|
68
|
+
GIF::Writer.open(path, width: frames.first.width, height: frames.first.height, loop:, palette: global_palette, colors:, dither:, optimize:, transparent_index:, clear_background: has_transparency) do |writer|
|
|
50
69
|
frames.zip(delays).each { |frame, frame_delay| writer.add(frame, delay: frame_delay) }
|
|
51
70
|
end
|
|
52
71
|
end
|
data/sig/flipbook.rbs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
module Flipbook
|
|
2
2
|
VERSION: String
|
|
3
3
|
|
|
4
|
-
def self.write: (String path, Array[Tessel::Image] frames, ?fps: Numeric?, ?delay: Numeric | Array[Numeric]?, ?loop: bool | Integer, ?colors: Integer, ?palette: Symbol | Array[[Integer, Integer, Integer]], ?dither: Symbol) -> String
|
|
4
|
+
def self.write: (String path, Array[Tessel::Image] frames, ?fps: Numeric?, ?delay: Numeric | Array[Numeric]?, ?loop: bool | Integer, ?format: String?, ?colors: Integer, ?palette: Symbol | Array[[Integer, Integer, Integer]], ?dither: Symbol, ?optimize: bool, **untyped) -> String
|
|
5
|
+
def self.read: (String path) -> Array[Tessel::Image]
|
|
5
6
|
|
|
6
7
|
module Timing
|
|
7
8
|
def self.delays: (Integer count, ?fps: Numeric?, ?delay: Numeric | Array[Numeric]?) -> Array[Rational]
|
|
8
9
|
def self.centiseconds: (Array[Numeric] delays) -> Array[Integer]
|
|
10
|
+
def self.apng_fraction: (Numeric delay) -> [Integer, Integer]
|
|
9
11
|
end
|
|
10
12
|
end
|
metadata
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: flipbook
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
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,7 +58,7 @@ dependencies:
|
|
|
57
58
|
- - "~>"
|
|
58
59
|
- !ruby/object:Gem::Version
|
|
59
60
|
version: '3.6'
|
|
60
|
-
description: Flipbook encodes animated GIF files from Tessel RGBA images.
|
|
61
|
+
description: Flipbook encodes animated GIF and APNG files from Tessel RGBA images.
|
|
61
62
|
email:
|
|
62
63
|
- t.yudai92@gmail.com
|
|
63
64
|
executables: []
|
|
@@ -70,8 +71,11 @@ files:
|
|
|
70
71
|
- Rakefile
|
|
71
72
|
- docs/verification.md
|
|
72
73
|
- lib/flipbook.rb
|
|
74
|
+
- lib/flipbook/apng/writer.rb
|
|
75
|
+
- lib/flipbook/diff.rb
|
|
73
76
|
- lib/flipbook/gif/bit_writer.rb
|
|
74
77
|
- lib/flipbook/gif/lzw.rb
|
|
78
|
+
- lib/flipbook/gif/reader.rb
|
|
75
79
|
- lib/flipbook/gif/writer.rb
|
|
76
80
|
- lib/flipbook/timing.rb
|
|
77
81
|
- lib/flipbook/version.rb
|
|
@@ -82,6 +86,7 @@ licenses:
|
|
|
82
86
|
metadata:
|
|
83
87
|
source_code_uri: https://github.com/rbgfx/flipbook
|
|
84
88
|
changelog_uri: https://github.com/rbgfx/flipbook/blob/main/CHANGELOG.md
|
|
89
|
+
post_install_message:
|
|
85
90
|
rdoc_options: []
|
|
86
91
|
require_paths:
|
|
87
92
|
- lib
|
|
@@ -96,7 +101,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
96
101
|
- !ruby/object:Gem::Version
|
|
97
102
|
version: '0'
|
|
98
103
|
requirements: []
|
|
99
|
-
rubygems_version: 4.
|
|
104
|
+
rubygems_version: 3.4.19
|
|
105
|
+
signing_key:
|
|
100
106
|
specification_version: 4
|
|
101
|
-
summary: Write
|
|
107
|
+
summary: Write and read GIF and APNG animations in Ruby
|
|
102
108
|
test_files: []
|