flipbook 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +76 -0
- data/Rakefile +12 -0
- data/docs/verification.md +13 -0
- data/lib/flipbook/gif/bit_writer.rb +30 -0
- data/lib/flipbook/gif/lzw.rb +51 -0
- data/lib/flipbook/gif/writer.rb +175 -0
- data/lib/flipbook/timing.rb +58 -0
- data/lib/flipbook/version.rb +5 -0
- data/lib/flipbook.rb +54 -0
- data/sig/flipbook.rbs +10 -0
- metadata +102 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4c21569bdedb373e7209ca4931199bfaa3039c62e606e3a09c5f49b708d2f3da
|
|
4
|
+
data.tar.gz: f0f977372a0d76697924494e6b9fc63ebbf535d23a01a4b9e80858649855cf0a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b814f5fbb5d1a743bb406e409b82317ddc3506acf7a8abde3f6317d2ffbba5fc1f2f9721e183fa09fb94113915a98deff2c6915983880ccf31da3002d4bf99de
|
|
7
|
+
data.tar.gz: 32f5830b2b7a5584d8e2b21af6727a550717e888f4d8b57c0631738695a10d35feba4084a357878794b18ce4233055b8d68f118aa705b449cab7f4f78c123c40
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<h1 align="center">Flipbook</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center">Turn a sequence of RGBA images into animated GIF files.</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://rubygems.org/gems/flipbook"><img src="https://badge.fury.io/rb/flipbook.svg" alt="Gem version"></a>
|
|
7
|
+
<a href="https://www.ruby-lang.org/"><img src="https://img.shields.io/badge/ruby-%3E%3D3.1-CC342D?logo=ruby&logoColor=white" alt="Ruby version"></a>
|
|
8
|
+
<a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-750014.svg" alt="License"></a>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
[Formats](#formats) · [Installation](#installation) · [API](#api) · [Development](#development)
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
Flipbook writes animated GIFs from [`Tessel::Image`](https://github.com/rbgfx/tessel) frames. GIF palettes use Tessel's shared quantizer. The library has no runtime dependency beyond Tessel and Ruby's standard library.
|
|
16
|
+
|
|
17
|
+
## Formats
|
|
18
|
+
|
|
19
|
+
| Format | Colors | Transparency | Encoding |
|
|
20
|
+
| --- | --- | --- | --- |
|
|
21
|
+
| GIF | Up to 256 palette entries | One transparent palette index | GIF89a output with global or per-frame palettes |
|
|
22
|
+
|
|
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
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
~~~ruby
|
|
28
|
+
gem "flipbook"
|
|
29
|
+
~~~
|
|
30
|
+
|
|
31
|
+
Then run `bundle install`. Flipbook requires Ruby 3.1 or newer and Tessel 0.2 or newer.
|
|
32
|
+
|
|
33
|
+
## API
|
|
34
|
+
|
|
35
|
+
~~~ruby
|
|
36
|
+
require "flipbook"
|
|
37
|
+
|
|
38
|
+
frames = 60.times.map do |index|
|
|
39
|
+
image = Tessel::Image.new(320, 240, fill: "#101827")
|
|
40
|
+
image.fill_rect(index * 4 % 320, 100, 32, 32, "#38bdf8")
|
|
41
|
+
image
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
Flipbook.write("animation.gif", frames, fps: 30, loop: true)
|
|
45
|
+
~~~
|
|
46
|
+
|
|
47
|
+
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.
|
|
48
|
+
|
|
49
|
+
For streaming GIF output, choose a per-frame palette:
|
|
50
|
+
|
|
51
|
+
~~~ruby
|
|
52
|
+
Flipbook::GIF::Writer.open("recording.gif", width: 320, height: 240, palette: :per_frame) do |writer|
|
|
53
|
+
writer.add(frame, delay: Rational(1, 30))
|
|
54
|
+
end
|
|
55
|
+
~~~
|
|
56
|
+
|
|
57
|
+
### API contracts
|
|
58
|
+
|
|
59
|
+
- `Flipbook.write(path, frames, fps: or delay:)` requires one or more same-sized `Tessel::Image` objects and a `.gif` output path.
|
|
60
|
+
- `GIF::Writer#add` raises `TypeError` for non-image frames and `ArgumentError` for invalid dimensions or timing.
|
|
61
|
+
- 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
|
+
|
|
63
|
+
GIF format acknowledgement: The Graphics Interchange Format© is the Copyright property of CompuServe Incorporated. GIF® is a Service Mark property of CompuServe Incorporated.
|
|
64
|
+
|
|
65
|
+
## Development
|
|
66
|
+
|
|
67
|
+
~~~sh
|
|
68
|
+
bundle install
|
|
69
|
+
bundle exec rake verify
|
|
70
|
+
~~~
|
|
71
|
+
|
|
72
|
+
Optional external checks for generated files are listed in [docs/verification.md](docs/verification.md).
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
[MIT](LICENSE.txt)
|
data/Rakefile
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# File verification
|
|
2
|
+
|
|
3
|
+
The test suite checks GIF LZW code-width growth and dictionary resets, frame timing, and transparent palette entries.
|
|
4
|
+
|
|
5
|
+
For an independent local check, install ImageMagick or gifsicle and inspect sample output:
|
|
6
|
+
|
|
7
|
+
~~~sh
|
|
8
|
+
identify animation.gif
|
|
9
|
+
magick animation.gif -coalesce /tmp/flipbook-gif-%02d.png
|
|
10
|
+
gifsicle --info animation.gif
|
|
11
|
+
~~~
|
|
12
|
+
|
|
13
|
+
These tools are optional and are not runtime dependencies.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flipbook
|
|
4
|
+
module GIF
|
|
5
|
+
class BitWriter
|
|
6
|
+
def initialize
|
|
7
|
+
@bytes = "".b
|
|
8
|
+
@buffer = 0
|
|
9
|
+
@bits = 0
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def write(code, width)
|
|
13
|
+
@buffer |= code << @bits
|
|
14
|
+
@bits += width
|
|
15
|
+
while @bits >= 8
|
|
16
|
+
@bytes << (@buffer & 255)
|
|
17
|
+
@buffer >>= 8
|
|
18
|
+
@bits -= 8
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def sub_blocks
|
|
23
|
+
@bytes << (@buffer & 255) if @bits.positive?
|
|
24
|
+
output = "".b
|
|
25
|
+
@bytes.bytes.each_slice(255) { |slice| output << slice.length << slice.pack("C*") }
|
|
26
|
+
output << "\0".b
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flipbook
|
|
4
|
+
module GIF
|
|
5
|
+
module LZW
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def encode(indices, minimum_code_size)
|
|
9
|
+
minimum_code_size = Integer(minimum_code_size)
|
|
10
|
+
raise ArgumentError, "minimum code size must be between 2 and 8" unless minimum_code_size.between?(2, 8)
|
|
11
|
+
|
|
12
|
+
clear_code = 1 << minimum_code_size
|
|
13
|
+
end_code = clear_code + 1
|
|
14
|
+
next_code = end_code + 1
|
|
15
|
+
code_width = minimum_code_size + 1
|
|
16
|
+
dictionary = {}
|
|
17
|
+
writer = BitWriter.new
|
|
18
|
+
writer.write(clear_code, code_width)
|
|
19
|
+
bytes = String(indices).b
|
|
20
|
+
return writer.tap { |bits| bits.write(end_code, code_width) }.sub_blocks if bytes.empty?
|
|
21
|
+
|
|
22
|
+
prefix = bytes.getbyte(0)
|
|
23
|
+
bytes.byteslice(1..).to_s.each_byte do |byte|
|
|
24
|
+
key = (prefix << 8) | byte
|
|
25
|
+
if dictionary.key?(key)
|
|
26
|
+
prefix = dictionary[key]
|
|
27
|
+
next
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
writer.write(prefix, code_width)
|
|
31
|
+
if next_code < 4096
|
|
32
|
+
dictionary[key] = next_code
|
|
33
|
+
next_code += 1
|
|
34
|
+
code_width += 1 if next_code > (1 << code_width) && code_width < 12
|
|
35
|
+
else
|
|
36
|
+
writer.write(clear_code, code_width)
|
|
37
|
+
dictionary.clear
|
|
38
|
+
next_code = end_code + 1
|
|
39
|
+
code_width = minimum_code_size + 1
|
|
40
|
+
end
|
|
41
|
+
prefix = byte
|
|
42
|
+
end
|
|
43
|
+
writer.write(prefix, code_width)
|
|
44
|
+
code_width += 1 if next_code == (1 << code_width) && code_width < 12
|
|
45
|
+
writer.write(end_code, code_width)
|
|
46
|
+
writer.sub_blocks
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flipbook
|
|
4
|
+
module GIF
|
|
5
|
+
class Writer
|
|
6
|
+
def self.open(path, **options)
|
|
7
|
+
io = File.open(path, "wb")
|
|
8
|
+
writer = new(io, **options)
|
|
9
|
+
writer.instance_variable_set(:@owns_io, true)
|
|
10
|
+
return writer unless block_given?
|
|
11
|
+
|
|
12
|
+
begin
|
|
13
|
+
yield writer
|
|
14
|
+
ensure
|
|
15
|
+
writer.close
|
|
16
|
+
end
|
|
17
|
+
path
|
|
18
|
+
rescue StandardError
|
|
19
|
+
io&.close unless io&.closed?
|
|
20
|
+
raise
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(io, width:, height:, loop: true, palette: :per_frame, colors: 256, dither: :floyd_steinberg, transparent_index: nil)
|
|
24
|
+
@io = io
|
|
25
|
+
@width = Integer(width)
|
|
26
|
+
@height = Integer(height)
|
|
27
|
+
raise ArgumentError, "GIF dimensions must be between 1 and 65535" unless @width.between?(1, 65_535) && @height.between?(1, 65_535)
|
|
28
|
+
raise ArgumentError, "colors must be between 2 and 256" unless colors.is_a?(Integer) && colors.between?(2, 256)
|
|
29
|
+
raise ArgumentError, "unknown dither: #{dither}" unless %i[none ordered floyd_steinberg].include?(dither)
|
|
30
|
+
|
|
31
|
+
@loop = loop_value(loop)
|
|
32
|
+
@colors = colors
|
|
33
|
+
@dither = dither
|
|
34
|
+
@elapsed = Rational(0)
|
|
35
|
+
@rounded_delay = 0
|
|
36
|
+
@closed = false
|
|
37
|
+
@warned_short_delay = false
|
|
38
|
+
@frame_count = 0
|
|
39
|
+
@palette_mode = palette
|
|
40
|
+
if palette == :per_frame
|
|
41
|
+
raise ArgumentError, "transparent_index is only valid with a global palette" unless transparent_index.nil?
|
|
42
|
+
@global_palette = nil
|
|
43
|
+
else
|
|
44
|
+
raise TypeError, "palette must be :per_frame or an RGB array" unless palette.is_a?(Array)
|
|
45
|
+
@global_palette = validate_palette(palette)
|
|
46
|
+
if transparent_index
|
|
47
|
+
raise ArgumentError, "transparent_index must be the last palette entry" unless transparent_index == @global_palette.length - 1
|
|
48
|
+
raise ArgumentError, "transparent_index is out of range" unless transparent_index.between?(0, @global_palette.length - 1)
|
|
49
|
+
raise ArgumentError, "global palette must include opaque and transparent colors" if @global_palette.length < 2
|
|
50
|
+
end
|
|
51
|
+
@global_transparent_index = transparent_index
|
|
52
|
+
end
|
|
53
|
+
write_header
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def add(image, delay:)
|
|
57
|
+
raise IOError, "GIF writer is closed" if @closed
|
|
58
|
+
raise TypeError, "frame must be a Tessel::Image" unless image.is_a?(Tessel::Image)
|
|
59
|
+
raise ArgumentError, "frame dimensions must match the logical screen" unless image.width == @width && image.height == @height
|
|
60
|
+
|
|
61
|
+
seconds = Timing.duration(delay)
|
|
62
|
+
if seconds < Rational(1, 50) && !@warned_short_delay
|
|
63
|
+
warn("Flipbook: delays below 1/50 second may be clamped by GIF viewers")
|
|
64
|
+
@warned_short_delay = true
|
|
65
|
+
end
|
|
66
|
+
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
|
+
elapsed = @elapsed + seconds
|
|
69
|
+
target_delay = (elapsed * 100).floor
|
|
70
|
+
frame_delay = target_delay - @rounded_delay
|
|
71
|
+
raise ArgumentError, "GIF frame delays cannot exceed 655.35 seconds" if frame_delay > 65_535
|
|
72
|
+
palette, opaque_palette, transparent_index = frame_palette(image, transparent_pixels)
|
|
73
|
+
indices, = Tessel::Quantize.quantize(image, palette: opaque_palette, dither: @dither)
|
|
74
|
+
if transparent_pixels
|
|
75
|
+
raise ArgumentError, "palette has no transparent entry" unless transparent_index
|
|
76
|
+
image.bytes.bytes.each_slice(4).with_index do |(_, _, _, alpha), index|
|
|
77
|
+
indices.setbyte(index, transparent_index) if alpha.zero?
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
write_control(frame_delay, transparent_index)
|
|
82
|
+
write_image(0, 0, @width, @height, indices, palette)
|
|
83
|
+
@elapsed = elapsed
|
|
84
|
+
@rounded_delay = target_delay
|
|
85
|
+
@frame_count += 1
|
|
86
|
+
self
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def close
|
|
90
|
+
return if @closed
|
|
91
|
+
raise Error, "GIF contains no frames" if @frame_count.zero?
|
|
92
|
+
|
|
93
|
+
@io << ";".b
|
|
94
|
+
@closed = true
|
|
95
|
+
@io.close if @owns_io && !@io.closed?
|
|
96
|
+
self
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def loop_value(value)
|
|
102
|
+
return nil if value == false
|
|
103
|
+
return 0 if value == true
|
|
104
|
+
return value if value.is_a?(Integer) && value.between?(0, 65_535)
|
|
105
|
+
|
|
106
|
+
raise ArgumentError, "loop must be true, false, or an integer from 0 to 65535"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def validate_palette(palette)
|
|
110
|
+
raise ArgumentError, "palette must contain 1 to 256 colors" unless palette.length.between?(1, 256)
|
|
111
|
+
palette.map do |color|
|
|
112
|
+
unless color.is_a?(Array) && color.length == 3 && color.all? { |channel| channel.is_a?(Integer) && channel.between?(0, 255) }
|
|
113
|
+
raise TypeError, "palette colors must be RGB triples"
|
|
114
|
+
end
|
|
115
|
+
color.dup
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def frame_palette(image, transparent_pixels)
|
|
120
|
+
if @global_palette
|
|
121
|
+
opaque = @global_transparent_index ? @global_palette[0...-1] : @global_palette
|
|
122
|
+
raise ArgumentError, "transparent pixels need a transparent palette entry" if transparent_pixels && !@global_transparent_index
|
|
123
|
+
return [@global_palette, opaque, @global_transparent_index]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
reserve = transparent_pixels
|
|
127
|
+
opaque_count = reserve ? [@colors - 1, 1].max : @colors
|
|
128
|
+
opaque = Tessel::Quantize.palette_for(image, colors: [opaque_count, 2].max).first(opaque_count)
|
|
129
|
+
palette = reserve ? opaque + [[0, 0, 0]] : opaque
|
|
130
|
+
[palette, opaque, reserve ? opaque.length : nil]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def write_header
|
|
134
|
+
@io << "GIF89a".b << [@width, @height].pack("v2")
|
|
135
|
+
if @global_palette
|
|
136
|
+
bits, size = table_size(@global_palette.length)
|
|
137
|
+
@io << [0x80 | 0x70 | (bits - 1), 0, 0].pack("C3")
|
|
138
|
+
write_palette(@global_palette, size)
|
|
139
|
+
else
|
|
140
|
+
@io << "\0\0\0".b
|
|
141
|
+
end
|
|
142
|
+
if @loop
|
|
143
|
+
@io << "!\xFF\x0BNETSCAPE2.0\x03\x01".b << [@loop].pack("v") << "\0".b
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def write_control(delay, transparent_index)
|
|
148
|
+
packed = 0
|
|
149
|
+
packed |= 0x01 if transparent_index
|
|
150
|
+
@io << "!\xF9\x04".b << [packed, delay, transparent_index || 0, 0].pack("CvCC")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def write_image(left, top, width, height, indices, palette)
|
|
154
|
+
local = !@global_palette
|
|
155
|
+
bits, size = table_size(palette.length)
|
|
156
|
+
descriptor_flags = local ? 0x80 | (bits - 1) : 0
|
|
157
|
+
@io << ",".b << [left, top, width, height, descriptor_flags].pack("v4C")
|
|
158
|
+
write_palette(palette, size) if local
|
|
159
|
+
minimum = [bits, 2].max
|
|
160
|
+
@io << minimum.chr(Encoding::BINARY) << LZW.encode(indices, minimum)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def table_size(count)
|
|
164
|
+
bits = [[(count - 1).bit_length, 1].max, 8].min
|
|
165
|
+
[bits, 1 << bits]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def write_palette(palette, size)
|
|
169
|
+
palette.each { |rgb| @io << rgb.pack("C3") }
|
|
170
|
+
(size - palette.length).times { @io << "\0\0\0".b }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flipbook
|
|
4
|
+
module Timing
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def delays(count, fps: nil, delay: nil)
|
|
8
|
+
raise ArgumentError, "frame count must be a positive integer" unless count.is_a?(Integer) && count.positive?
|
|
9
|
+
raise ArgumentError, "provide fps or delay, not both" if !fps.nil? && !delay.nil?
|
|
10
|
+
raise ArgumentError, "fps or delay is required" if fps.nil? && delay.nil?
|
|
11
|
+
|
|
12
|
+
values = if fps
|
|
13
|
+
rate = rational(fps)
|
|
14
|
+
raise ArgumentError, "fps must be positive" unless rate.positive?
|
|
15
|
+
Array.new(count, 1 / rate)
|
|
16
|
+
elsif delay.is_a?(Array)
|
|
17
|
+
raise ArgumentError, "delay count must match frame count" unless delay.length == count
|
|
18
|
+
delay.map { |value| positive_delay(value) }
|
|
19
|
+
else
|
|
20
|
+
Array.new(count) { positive_delay(delay) }
|
|
21
|
+
end
|
|
22
|
+
warn("Flipbook: delays below 1/50 second may be clamped by GIF viewers") if values.any? { |value| value < Rational(1, 50) }
|
|
23
|
+
values
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def duration(value)
|
|
27
|
+
positive_delay(value)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def centiseconds(delays)
|
|
31
|
+
elapsed = Rational(0)
|
|
32
|
+
rounded = 0
|
|
33
|
+
delays.map do |delay|
|
|
34
|
+
elapsed += rational(delay)
|
|
35
|
+
target = (elapsed * 100).floor
|
|
36
|
+
current = target - rounded
|
|
37
|
+
rounded = target
|
|
38
|
+
current
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def rational(value)
|
|
43
|
+
raise TypeError, "delay and fps must be numeric" unless value.is_a?(Numeric)
|
|
44
|
+
|
|
45
|
+
value.is_a?(Rational) ? value : Rational(value.to_s)
|
|
46
|
+
rescue ArgumentError, ZeroDivisionError
|
|
47
|
+
raise TypeError, "delay and fps must be numeric"
|
|
48
|
+
end
|
|
49
|
+
private_class_method :rational
|
|
50
|
+
|
|
51
|
+
def positive_delay(value)
|
|
52
|
+
result = rational(value)
|
|
53
|
+
raise ArgumentError, "delay must be positive" unless result.positive?
|
|
54
|
+
result
|
|
55
|
+
end
|
|
56
|
+
private_class_method :positive_delay
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/flipbook.rb
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tessel"
|
|
4
|
+
|
|
5
|
+
require_relative "flipbook/version"
|
|
6
|
+
require_relative "flipbook/timing"
|
|
7
|
+
require_relative "flipbook/gif/bit_writer"
|
|
8
|
+
require_relative "flipbook/gif/lzw"
|
|
9
|
+
require_relative "flipbook/gif/writer"
|
|
10
|
+
|
|
11
|
+
module Flipbook
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def write(path, frames, fps: nil, delay: nil, loop: true, colors: 256, palette: :global, dither: :floyd_steinberg)
|
|
17
|
+
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
|
+
raise ArgumentError, "at least one frame is required" if frames.empty?
|
|
19
|
+
raise ArgumentError, "frame dimensions must match" unless frames.all? { |frame| frame.width == frames.first.width && frame.height == frames.first.height }
|
|
20
|
+
|
|
21
|
+
delays = Timing.delays(frames.length, fps:, delay:)
|
|
22
|
+
raise ArgumentError, "output path must end in .gif" unless File.extname(String(path)).casecmp?(".gif")
|
|
23
|
+
|
|
24
|
+
write_gif(path, frames, delays, loop:, colors:, palette:, dither:)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def write_gif(path, frames, delays, loop:, colors:, palette:, dither:)
|
|
28
|
+
has_transparency = frames.any? { |frame| frame.bytes.bytes.each_slice(4).any? { |_, _, _, alpha| alpha.zero? } }
|
|
29
|
+
transparent = has_transparency
|
|
30
|
+
global_palette = if palette == :per_frame
|
|
31
|
+
raise ArgumentError, "per-frame palettes cannot preserve transparency across frames" if has_transparency
|
|
32
|
+
:per_frame
|
|
33
|
+
elsif palette == :global
|
|
34
|
+
opaque_count = transparent ? [colors - 1, 1].max : colors
|
|
35
|
+
opaque = Tessel::Quantize.palette_for(frames, colors: [opaque_count, 2].max).first(opaque_count)
|
|
36
|
+
transparent ? opaque + [[0, 0, 0]] : opaque
|
|
37
|
+
elsif palette.is_a?(Array)
|
|
38
|
+
if transparent
|
|
39
|
+
raise ArgumentError, "palette needs one free entry for transparency" if palette.length >= 256
|
|
40
|
+
palette + [[0, 0, 0]]
|
|
41
|
+
else
|
|
42
|
+
palette
|
|
43
|
+
end
|
|
44
|
+
else
|
|
45
|
+
raise ArgumentError, "palette must be :global, :per_frame, or an RGB array"
|
|
46
|
+
end
|
|
47
|
+
transparent_index = global_palette.is_a?(Array) && transparent ? global_palette.length - 1 : nil
|
|
48
|
+
|
|
49
|
+
GIF::Writer.open(path, width: frames.first.width, height: frames.first.height, loop:, palette: global_palette, colors:, dither:, transparent_index:) do |writer|
|
|
50
|
+
frames.zip(delays).each { |frame, frame_delay| writer.add(frame, delay: frame_delay) }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
private_class_method :write_gif
|
|
54
|
+
end
|
data/sig/flipbook.rbs
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module Flipbook
|
|
2
|
+
VERSION: String
|
|
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
|
|
5
|
+
|
|
6
|
+
module Timing
|
|
7
|
+
def self.delays: (Integer count, ?fps: Numeric?, ?delay: Numeric | Array[Numeric]?) -> Array[Rational]
|
|
8
|
+
def self.centiseconds: (Array[Numeric] delays) -> Array[Integer]
|
|
9
|
+
end
|
|
10
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: flipbook
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yudai Takada
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: tessel
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.2.0
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '1.0'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: 0.2.0
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '1.0'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: rake
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - "~>"
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '13.0'
|
|
39
|
+
type: :development
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - "~>"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '13.0'
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: test-unit
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - "~>"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '3.6'
|
|
53
|
+
type: :development
|
|
54
|
+
prerelease: false
|
|
55
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - "~>"
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '3.6'
|
|
60
|
+
description: Flipbook encodes animated GIF files from Tessel RGBA images.
|
|
61
|
+
email:
|
|
62
|
+
- t.yudai92@gmail.com
|
|
63
|
+
executables: []
|
|
64
|
+
extensions: []
|
|
65
|
+
extra_rdoc_files: []
|
|
66
|
+
files:
|
|
67
|
+
- CHANGELOG.md
|
|
68
|
+
- LICENSE.txt
|
|
69
|
+
- README.md
|
|
70
|
+
- Rakefile
|
|
71
|
+
- docs/verification.md
|
|
72
|
+
- lib/flipbook.rb
|
|
73
|
+
- lib/flipbook/gif/bit_writer.rb
|
|
74
|
+
- lib/flipbook/gif/lzw.rb
|
|
75
|
+
- lib/flipbook/gif/writer.rb
|
|
76
|
+
- lib/flipbook/timing.rb
|
|
77
|
+
- lib/flipbook/version.rb
|
|
78
|
+
- sig/flipbook.rbs
|
|
79
|
+
homepage: https://github.com/rbgfx/flipbook
|
|
80
|
+
licenses:
|
|
81
|
+
- MIT
|
|
82
|
+
metadata:
|
|
83
|
+
source_code_uri: https://github.com/rbgfx/flipbook
|
|
84
|
+
changelog_uri: https://github.com/rbgfx/flipbook/blob/main/CHANGELOG.md
|
|
85
|
+
rdoc_options: []
|
|
86
|
+
require_paths:
|
|
87
|
+
- lib
|
|
88
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - ">="
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: 3.1.0
|
|
93
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
94
|
+
requirements:
|
|
95
|
+
- - ">="
|
|
96
|
+
- !ruby/object:Gem::Version
|
|
97
|
+
version: '0'
|
|
98
|
+
requirements: []
|
|
99
|
+
rubygems_version: 4.0.16
|
|
100
|
+
specification_version: 4
|
|
101
|
+
summary: Write animated GIFs in Ruby
|
|
102
|
+
test_files: []
|