flipbook 0.3.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 +17 -4
- data/README.md +38 -15
- 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 +6 -4
- data/examples/generate.rb +49 -0
- data/lib/flipbook/apng/writer.rb +99 -56
- data/lib/flipbook/diff.rb +28 -17
- data/lib/flipbook/gif/lzw.rb +0 -66
- data/lib/flipbook/gif/reader.rb +205 -126
- data/lib/flipbook/gif/writer.rb +50 -52
- data/lib/flipbook/timing.rb +2 -13
- data/lib/flipbook/version.rb +1 -1
- data/lib/flipbook.rb +18 -20
- data/sig/flipbook.rbs +30 -4
- data/sig/tessel.rbs +4 -0
- metadata +11 -3
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,7 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [0.
|
|
3
|
+
## [0.4.0] - 2026-09-25
|
|
4
4
|
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
|
|
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
|
+
|
|
13
|
+
## [0.2.0] - 2026-09-25
|
|
14
|
+
|
|
15
|
+
- Add APNG writing with rational frame delays, alpha, and changed-region cropping.
|
|
16
|
+
- Crop unchanged regions in opaque GIF frames to reduce animation size.
|
|
17
|
+
|
|
18
|
+
## [0.1.0] - 2026-09-25
|
|
19
|
+
|
|
20
|
+
Initial release
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<h1 align="center">Flipbook</h1>
|
|
2
2
|
|
|
3
|
-
<p align="center">
|
|
3
|
+
<p align="center">Write optimized animated GIF and APNG files from Ruby images.</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
6
|
<a href="https://rubygems.org/gems/flipbook"><img src="https://badge.fury.io/rb/flipbook.svg" alt="Gem version"></a>
|
|
@@ -8,20 +8,20 @@
|
|
|
8
8
|
<a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-750014.svg" alt="License"></a>
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
|
-
[Formats](#formats) · [Installation](#installation) · [API](#api) · [Development](#development)
|
|
11
|
+
[Formats](#formats) · [Installation](#installation) · [API](#api) · [Examples](#examples) · [Development](#development)
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
-
Flipbook writes animated
|
|
15
|
+
Flipbook writes animated GIFs and APNGs from [`Tessel::Image`](https://github.com/rbgfx/tessel) frames. GIF palettes use Tessel's shared quantizer; APNG keeps full RGBA color. Both writers can crop unchanged frame regions.
|
|
16
16
|
|
|
17
17
|
## Formats
|
|
18
18
|
|
|
19
|
-
| Format |
|
|
19
|
+
| Format | Colors | Transparency | Encoding |
|
|
20
20
|
| --- | --- | --- | --- |
|
|
21
|
-
| GIF | Up to 256 palette entries | One transparent palette index |
|
|
22
|
-
| APNG |
|
|
21
|
+
| GIF | Up to 256 palette entries | One transparent palette index | GIF89a output with global or per-frame palettes and LZW |
|
|
22
|
+
| APNG (`.png`, `.apng`) | Full RGBA | Full alpha | PNG-compatible first frame, `acTL` / `fcTL` / `fdAT` animation chunks |
|
|
23
23
|
|
|
24
|
-
GIF uses a single global palette by default so colors stay stable across frames.
|
|
24
|
+
GIF uses a single global palette by default so colors stay stable across frames. Use `palette: :per_frame` when streaming frames with different color ranges. Delta cropping is most effective for opaque GIF frames; APNG supports alpha-safe cropped updates.
|
|
25
25
|
|
|
26
26
|
## Installation
|
|
27
27
|
|
|
@@ -43,9 +43,6 @@ frames = 60.times.map do |index|
|
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
Flipbook.write("animation.gif", frames, fps: 30, loop: true)
|
|
46
|
-
Flipbook.write("animation.png", frames, fps: 30, loop: true)
|
|
47
|
-
|
|
48
|
-
decoded_frames = Flipbook.read("animation.gif")
|
|
49
46
|
~~~
|
|
50
47
|
|
|
51
48
|
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.
|
|
@@ -58,15 +55,41 @@ Flipbook::GIF::Writer.open("recording.gif", width: 320, height: 240, palette: :p
|
|
|
58
55
|
end
|
|
59
56
|
~~~
|
|
60
57
|
|
|
61
|
-
|
|
58
|
+
Use a `.png` or `.apng` path for full-color animation. The first frame remains a valid PNG for viewers that do not animate APNG:
|
|
59
|
+
|
|
60
|
+
~~~ruby
|
|
61
|
+
Flipbook.write("animation.png", frames, fps: 30, loop: true)
|
|
62
|
+
~~~
|
|
63
|
+
|
|
64
|
+
`optimize: false` writes full-size frames. The default crops each changed frame to its smallest bounding rectangle. GIF cropping is used for opaque frames; APNG cropping preserves alpha changes.
|
|
65
|
+
|
|
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.
|
|
62
73
|
|
|
63
74
|
### API contracts
|
|
64
75
|
|
|
65
|
-
- `Flipbook.write(path, frames, fps: or delay:)` requires one or more same-sized `Tessel::Image` objects
|
|
66
|
-
- `
|
|
67
|
-
- `
|
|
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.
|
|
77
|
+
- `GIF::Writer#add` raises `TypeError` for non-image frames and `ArgumentError` for invalid dimensions or timing.
|
|
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.
|
|
68
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.
|
|
69
|
-
|
|
81
|
+
|
|
82
|
+
## Examples
|
|
83
|
+
|
|
84
|
+
Generate a rotating square, a color gradient, and transparent GIF/APNG samples:
|
|
85
|
+
|
|
86
|
+
~~~sh
|
|
87
|
+
ruby examples/generate.rb
|
|
88
|
+
~~~
|
|
89
|
+
|
|
90
|
+

|
|
91
|
+
|
|
92
|
+
Pass an output directory as the first argument to choose where the files are written.
|
|
70
93
|
|
|
71
94
|
GIF format acknowledgement: The Graphics Interchange Format© is the Copyright property of CompuServe Incorporated. GIF® is a Service Mark property of CompuServe Incorporated.
|
|
72
95
|
|
|
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,
|
|
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
|
-
identify animation.gif
|
|
8
|
+
identify animation.gif
|
|
9
9
|
magick animation.gif -coalesce /tmp/flipbook-gif-%02d.png
|
|
10
|
-
magick animation.png -coalesce /tmp/flipbook-apng-%02d.png
|
|
11
10
|
gifsicle --info animation.gif
|
|
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,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "flipbook"
|
|
5
|
+
|
|
6
|
+
output = ARGV.fetch(0, "examples/output")
|
|
7
|
+
FileUtils.mkdir_p(output)
|
|
8
|
+
|
|
9
|
+
frames = 24.times.map do |frame|
|
|
10
|
+
image = Tessel::Image.new(160, 120, fill: "#111827")
|
|
11
|
+
angle = frame * Math::PI / 12
|
|
12
|
+
cos = Math.cos(angle)
|
|
13
|
+
sin = Math.sin(angle)
|
|
14
|
+
(35...125).each do |y|
|
|
15
|
+
(55...105).each do |x|
|
|
16
|
+
dx = x - 80
|
|
17
|
+
dy = y - 60
|
|
18
|
+
next unless (dx * cos + dy * sin).abs <= 17 && (-dx * sin + dy * cos).abs <= 17
|
|
19
|
+
|
|
20
|
+
image[x, y] = [56, 189, 248, 255]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
image
|
|
24
|
+
end
|
|
25
|
+
Flipbook.write(File.join(output, "spinning-square.gif"), frames, fps: 12)
|
|
26
|
+
|
|
27
|
+
gradient = Tessel::Image.new(128, 32)
|
|
28
|
+
gradient.height.times do |y|
|
|
29
|
+
gradient.width.times do |x|
|
|
30
|
+
gradient[x, y] = [x * 2, 100, 255 - x * 2, 255]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
Flipbook.write(File.join(output, "gradient.gif"), [gradient], delay: 1)
|
|
34
|
+
|
|
35
|
+
transparent = 16.times.map do |frame|
|
|
36
|
+
image = Tessel::Image.new(96, 96)
|
|
37
|
+
center_x = 48 + (25 * Math.cos(frame * Math::PI / 8)).round
|
|
38
|
+
center_y = 48 + (25 * Math.sin(frame * Math::PI / 8)).round
|
|
39
|
+
96.times do |y|
|
|
40
|
+
96.times do |x|
|
|
41
|
+
image[x, y] = [251, 113, 133, 255] if (x - center_x)**2 + (y - center_y)**2 <= 14**2
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
image
|
|
45
|
+
end
|
|
46
|
+
Flipbook.write(File.join(output, "transparent.gif"), transparent, fps: 8)
|
|
47
|
+
Flipbook.write(File.join(output, "transparent.png"), transparent, fps: 8)
|
|
48
|
+
|
|
49
|
+
puts "Wrote GIF and APNG examples to #{output}"
|
data/lib/flipbook/apng/writer.rb
CHANGED
|
@@ -6,7 +6,7 @@ module Flipbook
|
|
|
6
6
|
module APNG
|
|
7
7
|
class Writer
|
|
8
8
|
def self.open(path, **options)
|
|
9
|
-
io = File.open(path, "wb
|
|
9
|
+
io = File.open(path, "wb")
|
|
10
10
|
writer = new(io, **options)
|
|
11
11
|
writer.instance_variable_set(:@owns_io, true)
|
|
12
12
|
return writer unless block_given?
|
|
@@ -22,61 +22,54 @@ module Flipbook
|
|
|
22
22
|
raise
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
def initialize(io, width:, height:, loop: true, optimize: true,
|
|
25
|
+
def initialize(io, width:, height:, loop: true, optimize: true, buffered: nil)
|
|
26
26
|
@io = io
|
|
27
27
|
@width = Integer(width)
|
|
28
28
|
@height = Integer(height)
|
|
29
|
-
raise ArgumentError, "APNG dimensions must be
|
|
30
|
-
raise
|
|
29
|
+
raise ArgumentError, "APNG dimensions must be between 1 and 2147483647" unless @width.between?(1, 2_147_483_647) && @height.between?(1, 2_147_483_647)
|
|
30
|
+
raise TypeError, "optimize must be true or false" unless [true, false].include?(optimize)
|
|
31
31
|
|
|
32
|
-
@loop =
|
|
32
|
+
@loop = loop_count(loop)
|
|
33
33
|
@optimize = optimize
|
|
34
|
-
@
|
|
35
|
-
@
|
|
36
|
-
@
|
|
37
|
-
@
|
|
38
|
-
@frames = 0
|
|
34
|
+
@buffered = buffered.nil? ? !seekable?(io) : buffered
|
|
35
|
+
@frames = []
|
|
36
|
+
@previous_image = nil
|
|
37
|
+
@frame_count = 0
|
|
39
38
|
@sequence = 0
|
|
40
|
-
@previous = nil
|
|
41
39
|
@closed = false
|
|
42
|
-
|
|
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"))
|
|
40
|
+
write_header unless @buffered
|
|
46
41
|
end
|
|
47
42
|
|
|
48
43
|
def add(image, delay:)
|
|
49
44
|
raise IOError, "APNG writer is closed" if @closed
|
|
50
45
|
raise TypeError, "frame must be a Tessel::Image" unless image.is_a?(Tessel::Image)
|
|
51
|
-
raise ArgumentError, "frame dimensions must match the
|
|
46
|
+
raise ArgumentError, "frame dimensions must match the canvas" unless image.width == @width && image.height == @height
|
|
52
47
|
|
|
53
48
|
seconds = Timing.duration(delay)
|
|
54
|
-
|
|
55
|
-
|
|
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)
|
|
49
|
+
if @buffered
|
|
50
|
+
@frames << [image.dup, seconds]
|
|
64
51
|
else
|
|
65
|
-
|
|
66
|
-
@sequence += 1
|
|
52
|
+
write_frame(image, seconds)
|
|
67
53
|
end
|
|
68
|
-
@
|
|
69
|
-
@
|
|
54
|
+
@frame_count += 1
|
|
55
|
+
@previous_image = image.dup
|
|
70
56
|
self
|
|
71
57
|
end
|
|
72
58
|
|
|
73
59
|
def close
|
|
74
|
-
return if @closed
|
|
75
|
-
raise Error, "APNG contains no frames" if @
|
|
60
|
+
return self if @closed
|
|
61
|
+
raise Error, "APNG contains no frames" if @frame_count.zero?
|
|
76
62
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
63
|
+
if @buffered
|
|
64
|
+
output = StringIO.new("".b)
|
|
65
|
+
writer = self.class.new(output, width: @width, height: @height, loop: @loop, optimize: @optimize, buffered: false)
|
|
66
|
+
@frames.each { |image, delay| writer.add(image, delay:) }
|
|
67
|
+
writer.close
|
|
68
|
+
@io.write(output.string)
|
|
69
|
+
else
|
|
70
|
+
write_chunk("IEND", "".b)
|
|
71
|
+
patch_animation_control
|
|
72
|
+
end
|
|
80
73
|
@closed = true
|
|
81
74
|
@io.close if @owns_io && !@io.closed?
|
|
82
75
|
self
|
|
@@ -84,40 +77,90 @@ module Flipbook
|
|
|
84
77
|
|
|
85
78
|
private
|
|
86
79
|
|
|
87
|
-
def
|
|
80
|
+
def seekable?(io)
|
|
81
|
+
io.respond_to?(:pos) && io.respond_to?(:seek)
|
|
82
|
+
rescue IOError, SystemCallError
|
|
83
|
+
false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def loop_count(value)
|
|
88
87
|
return 0 if value == true
|
|
89
88
|
return 1 if value == false
|
|
90
|
-
return value if value.is_a?(Integer) && value.between?(0,
|
|
89
|
+
return value if value.is_a?(Integer) && value.between?(0, 0xffff_ffff)
|
|
91
90
|
|
|
92
91
|
raise ArgumentError, "loop must be true, false, or an integer from 0 to 4294967295"
|
|
93
92
|
end
|
|
94
93
|
|
|
95
|
-
def
|
|
96
|
-
|
|
94
|
+
def write_header
|
|
95
|
+
@io << Tessel::SIGNATURE
|
|
96
|
+
write_chunk("IHDR", [@width, @height, 8, 6, 0, 0, 0].pack("N2C5"))
|
|
97
|
+
@actl_data_position = @io.pos + 8
|
|
98
|
+
write_chunk("acTL", [1, @loop].pack("N2"))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def write_frame(image, delay)
|
|
102
|
+
rectangle = if @frame_count.zero? || !@optimize
|
|
103
|
+
[0, 0, @width, @height]
|
|
104
|
+
else
|
|
105
|
+
Diff.bounds(@previous_image, image) || [0, 0, 1, 1]
|
|
106
|
+
end
|
|
107
|
+
x, y, width, height = rectangle
|
|
108
|
+
frame = rectangle == [0, 0, @width, @height] ? image : image.crop(x, y, width, height)
|
|
109
|
+
numerator, denominator = delay_fraction(delay)
|
|
110
|
+
alpha = frame.bytes.bytes.each_slice(4).any? { |_, _, _, channel| channel < 255 }
|
|
111
|
+
blend = @frame_count.zero? || alpha ? 0 : 1
|
|
112
|
+
control = [@sequence, width, height, x, y].pack("N5") + [numerator, denominator, 0, blend].pack("n2C2")
|
|
113
|
+
write_chunk("fcTL", control)
|
|
114
|
+
@sequence += 1
|
|
115
|
+
|
|
116
|
+
png = Tessel::PNG::Encoder.encode(frame)
|
|
117
|
+
idat = png_chunks(png).select { |type, _| type == "IDAT" }.map(&:last).join
|
|
118
|
+
if @frame_count.zero?
|
|
119
|
+
write_chunk("IDAT", idat)
|
|
120
|
+
else
|
|
121
|
+
write_chunk("fdAT", [@sequence].pack("N") + idat)
|
|
122
|
+
@sequence += 1
|
|
123
|
+
end
|
|
97
124
|
end
|
|
98
125
|
|
|
99
|
-
def
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
126
|
+
def delay_fraction(delay)
|
|
127
|
+
raise ArgumentError, "APNG frame delay cannot exceed 65535 seconds" if delay > 65_535
|
|
128
|
+
|
|
129
|
+
if delay.numerator <= 65_535 && delay.denominator <= 65_535
|
|
130
|
+
return [delay.numerator, delay.denominator]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
denominator = [65_535, (65_535 / delay).floor].min
|
|
134
|
+
denominator = 65_535 if denominator < 1
|
|
135
|
+
numerator = (delay * denominator).round.clamp(1, 65_535)
|
|
136
|
+
[numerator, denominator]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def png_chunks(png)
|
|
140
|
+
chunks = []
|
|
141
|
+
offset = Tessel::SIGNATURE.bytesize
|
|
142
|
+
while offset < png.bytesize
|
|
143
|
+
length = png.unpack1("N", offset:)
|
|
144
|
+
type = png.byteslice(offset + 4, 4)
|
|
145
|
+
data = png.byteslice(offset + 8, length)
|
|
146
|
+
chunks << [type, data]
|
|
147
|
+
offset += length + 12
|
|
108
148
|
break if type == "IEND"
|
|
109
149
|
end
|
|
110
|
-
|
|
150
|
+
chunks
|
|
151
|
+
end
|
|
111
152
|
|
|
112
|
-
|
|
153
|
+
def write_chunk(type, data)
|
|
154
|
+
Tessel::PNG::Chunk.write(@io, type, data)
|
|
113
155
|
end
|
|
114
156
|
|
|
115
|
-
def
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
@
|
|
119
|
-
|
|
120
|
-
@
|
|
157
|
+
def patch_animation_control
|
|
158
|
+
end_position = @io.pos
|
|
159
|
+
data = [@frame_count, @loop].pack("N2")
|
|
160
|
+
@io.seek(@actl_data_position)
|
|
161
|
+
@io.write(data)
|
|
162
|
+
@io.write([Zlib.crc32("acTL".b + data)].pack("N"))
|
|
163
|
+
@io.seek(end_position)
|
|
121
164
|
end
|
|
122
165
|
end
|
|
123
166
|
end
|
data/lib/flipbook/diff.rb
CHANGED
|
@@ -4,27 +4,38 @@ module Flipbook
|
|
|
4
4
|
module Diff
|
|
5
5
|
module_function
|
|
6
6
|
|
|
7
|
+
# Returns the smallest changed rectangle, or nil when the images match.
|
|
7
8
|
def bounds(previous, current)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
unless previous.is_a?(Tessel::Image) && current.is_a?(Tessel::Image)
|
|
10
|
+
raise TypeError, "frames must be Tessel::Image values"
|
|
11
|
+
end
|
|
12
|
+
unless previous.width == current.width && previous.height == current.height
|
|
13
|
+
raise ArgumentError, "frame dimensions must match"
|
|
14
|
+
end
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
left = current.width
|
|
17
|
+
top = current.height
|
|
18
|
+
right = -1
|
|
19
|
+
bottom = -1
|
|
20
|
+
before = previous.bytes
|
|
21
|
+
after = current.bytes
|
|
22
|
+
current.height.times do |y|
|
|
23
|
+
current.width.times do |x|
|
|
24
|
+
offset = (y * current.width + x) * 4
|
|
25
|
+
next if before.byteslice(offset, 4) == after.byteslice(offset, 4)
|
|
19
26
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
bottom = y if y > bottom
|
|
27
|
+
left = x if x < left
|
|
28
|
+
top = y if y < top
|
|
29
|
+
right = x if x > right
|
|
30
|
+
bottom = y if y > bottom
|
|
31
|
+
end
|
|
26
32
|
end
|
|
27
|
-
right.negative? ?
|
|
33
|
+
right.negative? ? nil : [left, top, right - left + 1, bottom - top + 1]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def crop(image, rectangle)
|
|
37
|
+
x, y, width, height = rectangle
|
|
38
|
+
image.crop(x, y, width, height)
|
|
28
39
|
end
|
|
29
40
|
end
|
|
30
41
|
end
|
data/lib/flipbook/gif/lzw.rb
CHANGED
|
@@ -46,72 +46,6 @@ 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
|
|
115
49
|
end
|
|
116
50
|
end
|
|
117
51
|
end
|