flipbook 0.2.0 → 0.4.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 +10 -0
- data/README.md +11 -0
- data/docs/assets/gradient.gif +0 -0
- data/docs/assets/spinning-square.gif +0 -0
- data/docs/assets/transparent.gif +0 -0
- data/docs/assets/transparent.png +0 -0
- data/docs/index.html +32 -0
- data/docs/verification.md +4 -2
- data/lib/flipbook/gif/reader.rb +268 -0
- data/lib/flipbook/gif/writer.rb +42 -21
- data/lib/flipbook/version.rb +1 -1
- data/lib/flipbook.rb +5 -0
- data/sig/flipbook.rbs +16 -0
- data/sig/tessel.rbs +4 -0
- metadata +16 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9873e25f97a747f0f45e26e9eb9c503228d6b2daa975a8976f64480c1b3e0ba0
|
|
4
|
+
data.tar.gz: 90bfb6579e71d4fdea9035fe86561fcde8a4911acb56f9ce8414136d0004abd6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: eb493019ec22fce32d0f4741bd1db36dbc8403b1528b267f8abb94b99865de2ceed88f50e24efdf145f760de33ea1526ce5206d258b6739043135a87da2ead85
|
|
7
|
+
data.tar.gz: e9a8476651fbfbe0cf0ff3a8092b0a7c0d3b9ee80a50c95a9a3ae8e0394c8275c84f5be424ce7ed65bcfa722a1002546e98d2f57ae630dd5cd599608caedb937
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.0] - 2026-09-25
|
|
4
|
+
|
|
5
|
+
- Read composited GIF frames with configurable image, frame, and total-pixel limits.
|
|
6
|
+
- Improve APNG writing and changed-region encoding.
|
|
7
|
+
|
|
8
|
+
## [0.3.0] - 2026-09-25
|
|
9
|
+
|
|
10
|
+
- Add GIF reading with LZW decoding, interlace handling, frame composition, and disposal methods.
|
|
11
|
+
- Clear transparent GIF frames correctly and retain delta cropping for opaque animations.
|
|
12
|
+
|
|
3
13
|
## [0.2.0] - 2026-09-25
|
|
4
14
|
|
|
5
15
|
- Add APNG writing with rational frame delays, alpha, and changed-region cropping.
|
data/README.md
CHANGED
|
@@ -63,11 +63,20 @@ Flipbook.write("animation.png", frames, fps: 30, loop: true)
|
|
|
63
63
|
|
|
64
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
65
|
|
|
66
|
+
Read GIFs as full-canvas `Tessel::Image` frames. The reader applies frame offsets, transparency, interlacing, and disposal before returning:
|
|
67
|
+
|
|
68
|
+
~~~ruby
|
|
69
|
+
frames = Flipbook.read("animation.gif")
|
|
70
|
+
~~~
|
|
71
|
+
|
|
72
|
+
`max_pixels:`, `max_frames:`, and `max_total_pixels:` can lower the decoder's resource limits for untrusted files.
|
|
73
|
+
|
|
66
74
|
### API contracts
|
|
67
75
|
|
|
68
76
|
- `Flipbook.write(path, frames, fps: or delay:)` requires one or more same-sized `Tessel::Image` objects and a `.gif`, `.png`, or `.apng` output path.
|
|
69
77
|
- `GIF::Writer#add` raises `TypeError` for non-image frames and `ArgumentError` for invalid dimensions or timing.
|
|
70
78
|
- `APNG::Writer#add` accepts positive numeric delays in seconds and raises for frames that do not match the canvas dimensions.
|
|
79
|
+
- `Flipbook.read(path)` returns fully composited GIF frames and rejects malformed streams or files over its configured limits.
|
|
71
80
|
- 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
81
|
|
|
73
82
|
## Examples
|
|
@@ -78,6 +87,8 @@ Generate a rotating square, a color gradient, and transparent GIF/APNG samples:
|
|
|
78
87
|
ruby examples/generate.rb
|
|
79
88
|
~~~
|
|
80
89
|
|
|
90
|
+

|
|
91
|
+
|
|
81
92
|
Pass an output directory as the first argument to choose where the files are written.
|
|
82
93
|
|
|
83
94
|
GIF format acknowledgement: The Graphics Interchange Format© is the Copyright property of CompuServe Incorporated. GIF® is a Service Mark property of CompuServe Incorporated.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
data/docs/index.html
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="theme-color" content="#101318">
|
|
7
|
+
<meta name="description" content="Write optimized animated GIF and APNG files from Ruby images with Flipbook.">
|
|
8
|
+
<title>Flipbook — animation from Ruby frames</title>
|
|
9
|
+
<style>
|
|
10
|
+
:root{color-scheme:dark;--bg:#101318;--panel:#181e27;--line:#2b3441;--text:#f1f4f8;--muted:#9ca9b8;--accent:#38bdf8;--mono:ui-monospace,SFMono-Regular,Menlo,monospace;--sans:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
11
|
+
*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 75% -12%,#16384c,transparent 45%),var(--bg);color:var(--text);font:16px/1.65 var(--sans)}a{color:inherit;text-decoration:none}a:hover{color:var(--accent)}.wrap{width:min(1080px,calc(100% - 40px));margin:auto}.nav{height:76px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{font-size:20px;font-weight:750;letter-spacing:-.04em}.brand span,.label{color:var(--accent)}.links{display:flex;gap:24px;color:var(--muted);font-size:14px}.hero{padding:84px 0 72px;display:grid;grid-template-columns:1fr .82fr;align-items:center;gap:56px}.label{font:12px var(--mono);text-transform:uppercase;letter-spacing:.14em}h1{font-size:clamp(48px,7vw,78px);line-height:.99;letter-spacing:-.075em;margin:18px 0}.hero p{font-size:18px;color:var(--muted);max-width:600px}.actions{display:flex;gap:12px;flex-wrap:wrap;margin:28px 0 22px}.button{padding:10px 16px;border:1px solid var(--line);border-radius:5px;font-weight:650}.primary{color:#061820;background:var(--accent);border-color:var(--accent)}.install{display:inline-flex;gap:16px;background:#090d12;border:1px solid var(--line);border-radius:6px;padding:12px 15px;font:14px var(--mono)}.install span{color:#6c7886}.visual{padding:20px;border:1px solid var(--line);border-radius:10px;background:linear-gradient(145deg,#1b2632,#11171e);box-shadow:0 24px 80px #0007;transform:rotate(1deg)}.window{height:34px;border-bottom:1px solid var(--line);margin:-2px 0 16px;display:flex;align-items:center;gap:7px}.dot{width:8px;height:8px;border-radius:50%;background:#566170}.filename{margin-left:8px;color:var(--muted);font:11px var(--mono)}.visual img{display:block;width:100%;border-radius:5px;image-rendering:auto}.caption{display:flex;justify-content:space-between;color:var(--muted);font:11px var(--mono);padding:12px 2px 0}.section{border-top:1px solid var(--line);padding:42px 0 66px}.section h2{font-size:28px;letter-spacing:-.04em;margin:8px 0}.section>p{color:var(--muted);max-width:650px}.formats{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:24px}.card{background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden}.card img{display:block;width:100%;height:200px;object-fit:contain;background:#0b1016;padding:18px}.cardbody{padding:16px 18px}.card h3{margin:0;font-size:16px}.card p{color:var(--muted);font-size:14px;margin:5px 0 0}.code{background:#090d12;border:1px solid var(--line);border-radius:7px;padding:18px;overflow:auto;font:14px/1.8 var(--mono);color:#d5eaf5}.foot{border-top:1px solid var(--line);padding:24px 0 36px;display:flex;justify-content:space-between;gap:16px;color:var(--muted);font-size:13px}
|
|
12
|
+
@media(max-width:740px){.hero{grid-template-columns:1fr;gap:32px;padding:66px 0 54px}.visual{max-width:500px}.links{gap:14px}.formats{grid-template-columns:1fr}.foot{flex-direction:column}}
|
|
13
|
+
</style>
|
|
14
|
+
</head>
|
|
15
|
+
<body><div class="wrap">
|
|
16
|
+
<nav class="nav" aria-label="Main navigation"><a class="brand" href="#top">flip<span>book</span></a><div class="links"><a href="#formats">Formats</a><a href="https://github.com/rbgfx/flipbook">GitHub</a><a href="https://rubygems.org/gems/flipbook">RubyGems</a></div></nav>
|
|
17
|
+
<main id="top">
|
|
18
|
+
<section class="hero"><div><div class="label">Animation from Ruby frames</div><h1>Every frame<br>in its place.</h1><p>Write optimized animated GIF and APNG files from Tessel images. Keep GIF palettes steady, preserve full-color alpha in APNG, and crop unchanged frame regions.</p><div class="actions"><a class="button primary" href="https://github.com/rbgfx/flipbook#api">Explore the API ↗</a><a class="button" href="https://github.com/rbgfx/flipbook/tree/main/examples">See examples</a></div><div class="install"><span>$</span> gem install flipbook</div></div><div class="visual"><div class="window"><i class="dot"></i><i class="dot"></i><i class="dot"></i><span class="filename">spinning-square.gif</span></div><img src="assets/spinning-square.gif" alt="A blue square rotating on a dark background"><div class="caption"><span>GIF · 12 FPS</span><span>24 frames</span></div></div></section>
|
|
19
|
+
<section class="section" id="formats"><div class="label">Pick the right format</div><h2>Palette animation or full color</h2><p>Use GIF for compact, broadly supported animations; choose APNG when full RGBA color and partial transparency matter.</p><div class="formats"><article class="card"><img src="assets/gradient.gif" alt="A blue-to-red color gradient"><div class="cardbody"><h3>GIF · up to 256 palette colors</h3><p>Global palettes keep colors consistent. Per-frame palettes are available for streaming.</p></div></article><article class="card"><img src="assets/transparent.png" alt="An animated pink circle with transparent background"><div class="cardbody"><h3>APNG · full RGBA</h3><p>A standard PNG first frame, with full alpha and optimized animated updates.</p></div></article></div></section>
|
|
20
|
+
<section class="section"><div class="label">Quick start</div><h2>Write from Tessel frames</h2><pre class="code"><code>require "flipbook"
|
|
21
|
+
|
|
22
|
+
frames = 60.times.map do |index|
|
|
23
|
+
image = Tessel::Image.new(320, 240, fill: "#101827")
|
|
24
|
+
image.fill_rect(index * 4 % 320, 100, 32, 32, "#38bdf8")
|
|
25
|
+
image
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
Flipbook.write("animation.gif", frames, fps: 30, loop: true)
|
|
29
|
+
Flipbook.write("animation.png", frames, fps: 30, loop: true)</code></pre></section>
|
|
30
|
+
</main><footer class="foot"><span>Flipbook · Animated GIF and APNG for Ruby</span><span><a href="https://github.com/rbgfx/flipbook">Source</a> · MIT License</span></footer>
|
|
31
|
+
</div></body>
|
|
32
|
+
</html>
|
data/docs/verification.md
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
# File verification
|
|
2
2
|
|
|
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.
|
|
3
|
+
The test suite checks GIF LZW code-width growth and dictionary resets, frame composition and disposal, resource limits, frame timing, transparent palette entries, cropped frame bounds, APNG sequence numbers, and PNG chunk CRCs.
|
|
4
4
|
|
|
5
|
-
For an independent local check, install ImageMagick or gifsicle and inspect sample output:
|
|
5
|
+
For an independent local check, install ImageMagick, FFmpeg, or gifsicle and inspect sample output:
|
|
6
6
|
|
|
7
7
|
~~~sh
|
|
8
8
|
identify animation.gif
|
|
9
9
|
magick animation.gif -coalesce /tmp/flipbook-gif-%02d.png
|
|
10
10
|
gifsicle --info animation.gif
|
|
11
11
|
magick identify animation.png
|
|
12
|
+
ffprobe -v error -count_frames -select_streams v:0 \\
|
|
13
|
+
-show_entries stream=codec_name,nb_read_frames animation.png
|
|
12
14
|
~~~
|
|
13
15
|
|
|
14
16
|
These tools are optional and are not runtime dependencies.
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flipbook
|
|
4
|
+
module GIF
|
|
5
|
+
class Reader
|
|
6
|
+
def self.read(path, max_pixels: 16_384 * 16_384, max_frames: 10_000, max_total_pixels: 100_000_000)
|
|
7
|
+
new(File.binread(path), max_pixels:, max_frames:, max_total_pixels:).read
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(bytes, max_pixels: 16_384 * 16_384, max_frames: 10_000, max_total_pixels: 100_000_000)
|
|
11
|
+
@bytes = String(bytes).b
|
|
12
|
+
@offset = 0
|
|
13
|
+
@max_pixels = Integer(max_pixels)
|
|
14
|
+
@max_frames = Integer(max_frames)
|
|
15
|
+
@max_total_pixels = Integer(max_total_pixels)
|
|
16
|
+
raise ArgumentError, "limits must be positive" unless @max_pixels.positive? && @max_frames.positive? && @max_total_pixels.positive?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def read
|
|
20
|
+
signature = take(6)
|
|
21
|
+
raise Error, "invalid GIF signature" unless %w[GIF87a GIF89a].include?(signature)
|
|
22
|
+
|
|
23
|
+
@width, @height, packed, background_index, = take(7).unpack("v2C3")
|
|
24
|
+
raise Error, "invalid GIF dimensions" if @width.zero? || @height.zero? || @width * @height > @max_pixels
|
|
25
|
+
|
|
26
|
+
@global_palette = read_palette(1 << ((packed & 7) + 1)) if (packed & 0x80).positive?
|
|
27
|
+
@background_index = background_index
|
|
28
|
+
@background = palette_color(@global_palette, background_index)
|
|
29
|
+
@frames = []
|
|
30
|
+
@canvas = Tessel::Image.new(@width, @height, fill: @background)
|
|
31
|
+
@control = default_control
|
|
32
|
+
@previous_disposal = nil
|
|
33
|
+
@finished = false
|
|
34
|
+
|
|
35
|
+
until @finished
|
|
36
|
+
marker = byte
|
|
37
|
+
case marker
|
|
38
|
+
when 0x21 then read_extension
|
|
39
|
+
when 0x2c then read_image
|
|
40
|
+
when 0x3b then @finished = true
|
|
41
|
+
else raise Error, "invalid GIF block marker 0x#{marker.to_s(16)}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
raise Error, "GIF contains no frames" if @frames.empty?
|
|
45
|
+
|
|
46
|
+
@frames
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def default_control
|
|
52
|
+
{ delay: 0, disposal: 0, transparent: nil }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def read_extension
|
|
56
|
+
label = byte
|
|
57
|
+
case label
|
|
58
|
+
when 0xf9
|
|
59
|
+
size = byte
|
|
60
|
+
raise Error, "invalid GIF graphic control extension" unless size == 4
|
|
61
|
+
|
|
62
|
+
packed, delay, transparent_index = take(4).unpack("C v C")
|
|
63
|
+
raise Error, "invalid GIF graphic control terminator" unless byte.zero?
|
|
64
|
+
disposal = (packed >> 2) & 7
|
|
65
|
+
raise Error, "unsupported GIF disposal method" if disposal > 3
|
|
66
|
+
|
|
67
|
+
@control = { delay: delay, disposal:, transparent: (packed & 1).positive? ? transparent_index : nil }
|
|
68
|
+
when 0x01
|
|
69
|
+
skip_sub_blocks # fixed plain-text header and text data are both sub-blocks
|
|
70
|
+
else
|
|
71
|
+
skip_sub_blocks
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def read_image
|
|
76
|
+
left, top, width, height, packed = take(9).unpack("v4C")
|
|
77
|
+
raise Error, "invalid GIF image dimensions" if width.zero? || height.zero?
|
|
78
|
+
raise Error, "GIF image rectangle is outside the canvas" if left + width > @width || top + height > @height
|
|
79
|
+
|
|
80
|
+
palette = (packed & 0x80).positive? ? read_palette(1 << ((packed & 7) + 1)) : @global_palette
|
|
81
|
+
raise Error, "GIF frame has no color table" unless palette
|
|
82
|
+
raise Error, "GIF transparent palette index is out of range" if @control[:transparent] && @control[:transparent] >= palette.length
|
|
83
|
+
minimum_code_size = byte
|
|
84
|
+
raise Error, "invalid GIF LZW code size" unless minimum_code_size.between?(2, 8)
|
|
85
|
+
|
|
86
|
+
indices = LZW.decode(read_sub_blocks, minimum_code_size, width * height)
|
|
87
|
+
indices = deinterlace(indices, width, height) if (packed & 0x40).positive?
|
|
88
|
+
draw_frame(indices, palette, left, top, width, height)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def draw_frame(indices, palette, left, top, width, height)
|
|
92
|
+
disposal = @control[:disposal]
|
|
93
|
+
if @previous_disposal
|
|
94
|
+
previous = @previous_disposal
|
|
95
|
+
case previous[:method]
|
|
96
|
+
when 2
|
|
97
|
+
@canvas.fill_rect(*previous[:rect], previous[:background])
|
|
98
|
+
when 3
|
|
99
|
+
@canvas = previous[:restore]
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
raise Error, "GIF frame limit exceeded" if @frames.length >= @max_frames
|
|
104
|
+
raise Error, "GIF total pixel limit exceeded" if (@frames.length + 1) * @width * @height > @max_total_pixels
|
|
105
|
+
restore = @canvas.dup if disposal == 3
|
|
106
|
+
transparent = @control[:transparent]
|
|
107
|
+
transparent_background = transparent && (!@global_palette || transparent == @background_index)
|
|
108
|
+
@canvas.clear([0, 0, 0, 0]) if @frames.empty? && transparent_background
|
|
109
|
+
indices.each_with_index do |palette_index, index|
|
|
110
|
+
next if palette_index == transparent
|
|
111
|
+
|
|
112
|
+
color = palette[palette_index]
|
|
113
|
+
raise Error, "GIF palette index is out of range" unless color
|
|
114
|
+
|
|
115
|
+
x = left + index % width
|
|
116
|
+
y = top + index / width
|
|
117
|
+
@canvas[x, y] = [*color, 255]
|
|
118
|
+
end
|
|
119
|
+
@frames << @canvas.dup
|
|
120
|
+
background = if transparent_background
|
|
121
|
+
[0, 0, 0, 0]
|
|
122
|
+
else
|
|
123
|
+
[*@background, 255]
|
|
124
|
+
end
|
|
125
|
+
@previous_disposal = { method: disposal, rect: [left, top, width, height], background:, restore: }
|
|
126
|
+
@control = default_control
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def deinterlace(indices, width, height)
|
|
130
|
+
rows = Array.new(height)
|
|
131
|
+
offset = 0
|
|
132
|
+
[[0, 8], [4, 8], [2, 4], [1, 2]].each do |start, step|
|
|
133
|
+
(start...height).step(step) do |y|
|
|
134
|
+
rows[y] = indices.slice(offset, width)
|
|
135
|
+
offset += width
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
rows.flatten
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def read_palette(count)
|
|
142
|
+
take(count * 3).unpack("C*").each_slice(3).map(&:freeze)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def palette_color(palette, index)
|
|
146
|
+
color = palette&.[](index)
|
|
147
|
+
color || [0, 0, 0]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def read_sub_blocks
|
|
151
|
+
output = "".b
|
|
152
|
+
loop do
|
|
153
|
+
length = byte
|
|
154
|
+
break if length.zero?
|
|
155
|
+
|
|
156
|
+
output << take(length)
|
|
157
|
+
end
|
|
158
|
+
output
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def skip_sub_blocks
|
|
162
|
+
loop do
|
|
163
|
+
length = byte
|
|
164
|
+
break if length.zero?
|
|
165
|
+
|
|
166
|
+
take(length)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def take(length)
|
|
171
|
+
raise Error, "truncated GIF data" if length.negative? || @offset + length > @bytes.bytesize
|
|
172
|
+
|
|
173
|
+
result = @bytes.byteslice(@offset, length)
|
|
174
|
+
@offset += length
|
|
175
|
+
result
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def byte
|
|
179
|
+
take(1).getbyte(0)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
module LZW
|
|
184
|
+
module_function
|
|
185
|
+
|
|
186
|
+
def decode(bytes, minimum_code_size, expected_size)
|
|
187
|
+
clear = 1 << minimum_code_size
|
|
188
|
+
ending = clear + 1
|
|
189
|
+
prefix = Array.new(4096)
|
|
190
|
+
suffix = Array.new(4096)
|
|
191
|
+
clear.times { |index| suffix[index] = index }
|
|
192
|
+
next_code = ending + 1
|
|
193
|
+
width = minimum_code_size + 1
|
|
194
|
+
bit_offset = 0
|
|
195
|
+
previous = nil
|
|
196
|
+
first = nil
|
|
197
|
+
output = String.new(capacity: expected_size, encoding: Encoding::BINARY)
|
|
198
|
+
ended = false
|
|
199
|
+
|
|
200
|
+
loop do
|
|
201
|
+
code = read_code(bytes, bit_offset, width)
|
|
202
|
+
break unless code
|
|
203
|
+
|
|
204
|
+
bit_offset += width
|
|
205
|
+
if code == clear
|
|
206
|
+
clear.times { |index| suffix[index] = index }
|
|
207
|
+
next_code = ending + 1
|
|
208
|
+
width = minimum_code_size + 1
|
|
209
|
+
previous = nil
|
|
210
|
+
next
|
|
211
|
+
end
|
|
212
|
+
if code == ending
|
|
213
|
+
ended = true
|
|
214
|
+
break
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
if code < next_code && suffix[code]
|
|
218
|
+
entry, first = expand(code, clear, prefix, suffix)
|
|
219
|
+
elsif code == next_code && previous
|
|
220
|
+
entry, first = expand(previous, clear, prefix, suffix)
|
|
221
|
+
entry << first
|
|
222
|
+
else
|
|
223
|
+
raise Error, "invalid GIF LZW code"
|
|
224
|
+
end
|
|
225
|
+
output << entry.pack("C*")
|
|
226
|
+
raise Error, "GIF frame data exceeds its dimensions" if output.bytesize > expected_size
|
|
227
|
+
|
|
228
|
+
if previous && next_code < 4096
|
|
229
|
+
prefix[next_code] = previous
|
|
230
|
+
suffix[next_code] = first
|
|
231
|
+
next_code += 1
|
|
232
|
+
width += 1 if next_code == (1 << width) && width < 12
|
|
233
|
+
end
|
|
234
|
+
previous = code
|
|
235
|
+
end
|
|
236
|
+
raise Error, "truncated GIF LZW data" unless ended && output.bytesize == expected_size
|
|
237
|
+
|
|
238
|
+
output.bytes
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def expand(code, clear, prefix, suffix)
|
|
242
|
+
stack = []
|
|
243
|
+
current = code
|
|
244
|
+
while current >= clear
|
|
245
|
+
raise Error, "invalid GIF LZW dictionary" unless current < 4096 && suffix[current] && prefix[current]
|
|
246
|
+
|
|
247
|
+
stack << suffix[current]
|
|
248
|
+
current = prefix[current]
|
|
249
|
+
end
|
|
250
|
+
stack << current
|
|
251
|
+
first = current
|
|
252
|
+
[stack.reverse, first]
|
|
253
|
+
end
|
|
254
|
+
private_class_method :expand
|
|
255
|
+
|
|
256
|
+
def read_code(bytes, bit_offset, width)
|
|
257
|
+
return nil if bit_offset + width > bytes.bytesize * 8
|
|
258
|
+
|
|
259
|
+
code = 0
|
|
260
|
+
width.times do |bit|
|
|
261
|
+
code |= ((bytes.getbyte((bit_offset + bit) / 8) >> ((bit_offset + bit) % 8)) & 1) << bit
|
|
262
|
+
end
|
|
263
|
+
code
|
|
264
|
+
end
|
|
265
|
+
private_class_method :read_code
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
data/lib/flipbook/gif/writer.rb
CHANGED
|
@@ -39,10 +39,13 @@ module Flipbook
|
|
|
39
39
|
@closed = false
|
|
40
40
|
@warned_short_delay = false
|
|
41
41
|
@frame_count = 0
|
|
42
|
+
@pending_frame = nil
|
|
43
|
+
@canvas_cleared = false
|
|
42
44
|
@palette_mode = palette
|
|
43
45
|
if palette == :per_frame
|
|
44
46
|
raise ArgumentError, "transparent_index is only valid with a global palette" unless transparent_index.nil?
|
|
45
47
|
@global_palette = nil
|
|
48
|
+
@global_transparent_index = 0
|
|
46
49
|
else
|
|
47
50
|
raise TypeError, "palette must be :per_frame or an RGB array" unless palette.is_a?(Array)
|
|
48
51
|
@global_palette = validate_palette(palette)
|
|
@@ -67,28 +70,22 @@ module Flipbook
|
|
|
67
70
|
@warned_short_delay = true
|
|
68
71
|
end
|
|
69
72
|
transparent_pixels = image.bytes.bytes.each_slice(4).any? { |_, _, _, alpha| alpha.zero? }
|
|
73
|
+
if @pending_frame
|
|
74
|
+
disposal = (@pending_frame[:transparent] || transparent_pixels) ? 2 : 1
|
|
75
|
+
write_pending(disposal)
|
|
76
|
+
@canvas_cleared = disposal == 2
|
|
77
|
+
end
|
|
78
|
+
|
|
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
|
-
|
|
75
|
-
rectangle = if @optimize && @previous_image && !transparent_pixels
|
|
83
|
+
rectangle = if @optimize && @previous_image && !transparent_pixels && !@canvas_cleared
|
|
76
84
|
Diff.bounds(@previous_image, image) || [0, 0, 1, 1]
|
|
77
85
|
else
|
|
78
86
|
[0, 0, @width, @height]
|
|
79
87
|
end
|
|
80
|
-
|
|
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)
|
|
83
|
-
if transparent_pixels
|
|
84
|
-
raise ArgumentError, "palette has no transparent entry" unless transparent_index
|
|
85
|
-
frame.bytes.bytes.each_slice(4).with_index do |(_, _, _, alpha), index|
|
|
86
|
-
indices.setbyte(index, transparent_index) if alpha.zero?
|
|
87
|
-
end
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
write_control(frame_delay, transparent_index)
|
|
91
|
-
write_image(left, top, width, height, indices, palette)
|
|
88
|
+
@pending_frame = { image: image.dup, delay: frame_delay, rectangle:, transparent: transparent_pixels }
|
|
92
89
|
@elapsed = elapsed
|
|
93
90
|
@rounded_delay = target_delay
|
|
94
91
|
@frame_count += 1
|
|
@@ -100,6 +97,7 @@ module Flipbook
|
|
|
100
97
|
return if @closed
|
|
101
98
|
raise Error, "GIF contains no frames" if @frame_count.zero?
|
|
102
99
|
|
|
100
|
+
write_pending(@pending_frame[:transparent] ? 2 : 1)
|
|
103
101
|
@io << ";".b
|
|
104
102
|
@closed = true
|
|
105
103
|
@io.close if @owns_io && !@io.closed?
|
|
@@ -133,11 +131,33 @@ module Flipbook
|
|
|
133
131
|
return [@global_palette, opaque, @global_transparent_index]
|
|
134
132
|
end
|
|
135
133
|
|
|
136
|
-
|
|
137
|
-
opaque_count = reserve ? [@colors - 1, 1].max : @colors
|
|
134
|
+
opaque_count = [@colors - 1, 1].max
|
|
138
135
|
opaque = Tessel::Quantize.palette_for(image, colors: [opaque_count, 2].max).first(opaque_count)
|
|
139
|
-
|
|
140
|
-
|
|
136
|
+
[[[0, 0, 0]] + opaque, opaque, 0]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def write_pending(disposal)
|
|
140
|
+
pending = @pending_frame
|
|
141
|
+
image = pending[:image]
|
|
142
|
+
left, top, width, height = pending[:rectangle]
|
|
143
|
+
frame = if left.zero? && top.zero? && width == @width && height == @height
|
|
144
|
+
image
|
|
145
|
+
else
|
|
146
|
+
image.crop(left, top, width, height)
|
|
147
|
+
end
|
|
148
|
+
palette, opaque_palette, transparent_index = frame_palette(image, pending[:transparent])
|
|
149
|
+
indices, = Tessel::Quantize.quantize(frame, palette: opaque_palette, dither: @dither)
|
|
150
|
+
indices.each_byte.with_index { |index, offset| indices.setbyte(offset, index + 1) } if @palette_mode == :per_frame
|
|
151
|
+
if pending[:transparent]
|
|
152
|
+
raise ArgumentError, "palette has no transparent entry" unless transparent_index
|
|
153
|
+
frame.bytes.bytes.each_slice(4).with_index do |(_, _, _, alpha), index|
|
|
154
|
+
indices.setbyte(index, transparent_index) if alpha.zero?
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
write_control(pending[:delay], transparent_index, disposal:)
|
|
159
|
+
write_image(left, top, width, height, indices, palette)
|
|
160
|
+
@pending_frame = nil
|
|
141
161
|
end
|
|
142
162
|
|
|
143
163
|
def write_header
|
|
@@ -147,15 +167,16 @@ module Flipbook
|
|
|
147
167
|
@io << [0x80 | 0x70 | (bits - 1), @global_transparent_index || 0, 0].pack("C3")
|
|
148
168
|
write_palette(@global_palette, size)
|
|
149
169
|
else
|
|
150
|
-
@io <<
|
|
170
|
+
@io << [0x80 | 0x70, 0, 0].pack("C3")
|
|
171
|
+
write_palette([[0, 0, 0]], 2)
|
|
151
172
|
end
|
|
152
173
|
if @loop
|
|
153
174
|
@io << "!\xFF\x0BNETSCAPE2.0\x03\x01".b << [@loop].pack("v") << "\0".b
|
|
154
175
|
end
|
|
155
176
|
end
|
|
156
177
|
|
|
157
|
-
def write_control(delay, transparent_index)
|
|
158
|
-
packed =
|
|
178
|
+
def write_control(delay, transparent_index, disposal:)
|
|
179
|
+
packed = disposal << 2
|
|
159
180
|
packed |= 0x01 if transparent_index
|
|
160
181
|
@io << "!\xF9\x04".b << [packed, delay, transparent_index || 0, 0].pack("CvCC")
|
|
161
182
|
end
|
data/lib/flipbook/version.rb
CHANGED
data/lib/flipbook.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative "flipbook/version"
|
|
|
6
6
|
require_relative "flipbook/timing"
|
|
7
7
|
require_relative "flipbook/gif/bit_writer"
|
|
8
8
|
require_relative "flipbook/gif/lzw"
|
|
9
|
+
require_relative "flipbook/gif/reader"
|
|
9
10
|
require_relative "flipbook/gif/writer"
|
|
10
11
|
require_relative "flipbook/apng/writer"
|
|
11
12
|
require_relative "flipbook/diff"
|
|
@@ -35,6 +36,10 @@ module Flipbook
|
|
|
35
36
|
end
|
|
36
37
|
end
|
|
37
38
|
|
|
39
|
+
def read(path, **options)
|
|
40
|
+
GIF::Reader.read(path, **options)
|
|
41
|
+
end
|
|
42
|
+
|
|
38
43
|
def write_gif(path, frames, delays, loop:, colors:, palette:, dither:, optimize:)
|
|
39
44
|
has_transparency = frames.any? { |frame| frame.bytes.bytes.each_slice(4).any? { |_, _, _, alpha| alpha.zero? } }
|
|
40
45
|
transparent = has_transparency
|
data/sig/flipbook.rbs
CHANGED
|
@@ -2,6 +2,7 @@ module Flipbook
|
|
|
2
2
|
VERSION: String
|
|
3
3
|
|
|
4
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
|
+
def self.read: (String path, ?max_pixels: Integer, ?max_frames: Integer, ?max_total_pixels: Integer) -> Array[Tessel::Image]
|
|
5
6
|
|
|
6
7
|
module Timing
|
|
7
8
|
def self.delays: (Integer count, ?fps: Numeric?, ?delay: Numeric | Array[Numeric]?, ?warn_short: bool) -> Array[Rational]
|
|
@@ -16,6 +17,21 @@ module Flipbook
|
|
|
16
17
|
end
|
|
17
18
|
end
|
|
18
19
|
|
|
20
|
+
module GIF
|
|
21
|
+
class Writer
|
|
22
|
+
def self.open: (String path, width: Integer, height: Integer, ?loop: bool | Integer, ?palette: Symbol | Array[[Integer, Integer, Integer]], ?colors: Integer, ?dither: Symbol, ?transparent_index: Integer?, ?optimize: bool) { (Writer) -> void } -> String
|
|
23
|
+
def add: (Tessel::Image image, delay: Numeric) -> Writer
|
|
24
|
+
def close: () -> Writer
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class Reader
|
|
28
|
+
def self.read: (String path, ?max_pixels: Integer, ?max_frames: Integer, ?max_total_pixels: Integer) -> Array[Tessel::Image]
|
|
29
|
+
def read: () -> Array[Tessel::Image]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.read: (String path, ?max_pixels: Integer, ?max_frames: Integer, ?max_total_pixels: Integer) -> Array[Tessel::Image]
|
|
33
|
+
end
|
|
34
|
+
|
|
19
35
|
module Diff
|
|
20
36
|
def self.bounds: (Tessel::Image previous, Tessel::Image current) -> [Integer, Integer, Integer, Integer]?
|
|
21
37
|
end
|
data/sig/tessel.rbs
ADDED
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.4.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,8 @@ dependencies:
|
|
|
57
58
|
- - "~>"
|
|
58
59
|
- !ruby/object:Gem::Version
|
|
59
60
|
version: '3.6'
|
|
60
|
-
description: Flipbook encodes optimized
|
|
61
|
-
images.
|
|
61
|
+
description: Flipbook encodes optimized GIF and APNG animations and reads composited
|
|
62
|
+
GIF frames from Tessel images.
|
|
62
63
|
email:
|
|
63
64
|
- t.yudai92@gmail.com
|
|
64
65
|
executables: []
|
|
@@ -69,6 +70,11 @@ files:
|
|
|
69
70
|
- LICENSE.txt
|
|
70
71
|
- README.md
|
|
71
72
|
- Rakefile
|
|
73
|
+
- docs/assets/gradient.gif
|
|
74
|
+
- docs/assets/spinning-square.gif
|
|
75
|
+
- docs/assets/transparent.gif
|
|
76
|
+
- docs/assets/transparent.png
|
|
77
|
+
- docs/index.html
|
|
72
78
|
- docs/verification.md
|
|
73
79
|
- examples/generate.rb
|
|
74
80
|
- lib/flipbook.rb
|
|
@@ -76,16 +82,19 @@ files:
|
|
|
76
82
|
- lib/flipbook/diff.rb
|
|
77
83
|
- lib/flipbook/gif/bit_writer.rb
|
|
78
84
|
- lib/flipbook/gif/lzw.rb
|
|
85
|
+
- lib/flipbook/gif/reader.rb
|
|
79
86
|
- lib/flipbook/gif/writer.rb
|
|
80
87
|
- lib/flipbook/timing.rb
|
|
81
88
|
- lib/flipbook/version.rb
|
|
82
89
|
- sig/flipbook.rbs
|
|
90
|
+
- sig/tessel.rbs
|
|
83
91
|
homepage: https://github.com/rbgfx/flipbook
|
|
84
92
|
licenses:
|
|
85
93
|
- MIT
|
|
86
94
|
metadata:
|
|
87
95
|
source_code_uri: https://github.com/rbgfx/flipbook
|
|
88
96
|
changelog_uri: https://github.com/rbgfx/flipbook/blob/main/CHANGELOG.md
|
|
97
|
+
post_install_message:
|
|
89
98
|
rdoc_options: []
|
|
90
99
|
require_paths:
|
|
91
100
|
- lib
|
|
@@ -100,7 +109,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
100
109
|
- !ruby/object:Gem::Version
|
|
101
110
|
version: '0'
|
|
102
111
|
requirements: []
|
|
103
|
-
rubygems_version: 4.
|
|
112
|
+
rubygems_version: 3.4.19
|
|
113
|
+
signing_key:
|
|
104
114
|
specification_version: 4
|
|
105
|
-
summary: Write animated GIF and APNG files in Ruby
|
|
115
|
+
summary: Write and read animated GIF and APNG files in Ruby
|
|
106
116
|
test_files: []
|