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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ed6c3ae7ae41a6819cbd15890ebe7002b9637c2a2afb51e1f185ac7cf4c4b39c
4
- data.tar.gz: 59c4fcbf5f6fce4baa22c359a54334d873403bf2ee6904ab483770cca41a5ab1
3
+ metadata.gz: 9873e25f97a747f0f45e26e9eb9c503228d6b2daa975a8976f64480c1b3e0ba0
4
+ data.tar.gz: 90bfb6579e71d4fdea9035fe86561fcde8a4911acb56f9ce8414136d0004abd6
5
5
  SHA512:
6
- metadata.gz: 3047e42608ce4f1d5653fa4573fc2d9c210e5b732557bb65cf2f41f8087aa8f6b81c1a3e895f4cadc3e03c8c83b532bfaa8ea51a47bd557bcf890bd0fb42e949
7
- data.tar.gz: 44abccb00089ba9fe0d6b16657c36c2967d506a69e923e42d31971260b09461d405f755e3d195f6fd5a50aadea68971686b0ba101217d1b74bf524887183f375
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] - 2026-09-24
3
+ ## [0.4.0] - 2026-09-25
4
4
 
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.
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">Turn a sequence of RGBA images into animated GIF or APNG files.</p>
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 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.
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 | Color | Transparency | Reading |
19
+ | Format | Colors | Transparency | Encoding |
20
20
  | --- | --- | --- | --- |
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 |
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. 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
+ 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
- APNG also supports streaming output and patches its frame count when the destination can seek. Otherwise it buffers the animation until close.
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. 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.
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
- - APNG chunks use RGBA8 pixels. The first frame is also a valid static PNG image.
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
+ ![A blue square rotating in an animated GIF](docs/assets/spinning-square.gif)
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, decodes generated animations, and validates APNG chunk ordering and sequence numbers.
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 animation.png
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}"
@@ -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, level: Zlib::DEFAULT_COMPRESSION, filter: :none)
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 positive" unless @width.positive? && @height.positive?
30
- raise ArgumentError, "unknown PNG filter: #{filter}" unless %i[none sub up average paeth adaptive].include?(filter)
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 = plays(loop)
32
+ @loop = loop_count(loop)
33
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
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
- @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"))
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 animation" unless image.width == @width && image.height == @height
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
- 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)
49
+ if @buffered
50
+ @frames << [image.dup, seconds]
64
51
  else
65
- write_chunk("fdAT", [@sequence].pack("N") + compressed)
66
- @sequence += 1
52
+ write_frame(image, seconds)
67
53
  end
68
- @frames += 1
69
- @previous = image.dup
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 @frames.zero?
60
+ return self if @closed
61
+ raise Error, "APNG contains no frames" if @frame_count.zero?
76
62
 
77
- write_chunk("IEND", "".b)
78
- finish_animation_control
79
- @io << @storage.string if @storage != @io
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 plays(value)
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, 0xFFFF_FFFF)
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 write_chunk(type, data)
96
- Tessel::PNG::Chunk.write(@storage, type, data)
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 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
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
- raise Error, "Tessel produced a PNG without image data" if output.empty?
150
+ chunks
151
+ end
111
152
 
112
- output
153
+ def write_chunk(type, data)
154
+ Tessel::PNG::Chunk.write(@io, type, data)
113
155
  end
114
156
 
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)
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
- 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
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
- 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)
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
- 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
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? ? [0, 0, 1, 1] : [left, top, right - left + 1, bottom - top + 1]
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
@@ -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