wezen 0.1.0 → 0.1.1

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: 371481b9c7234ab5048aeb49b4ee4413c71aee57c46331130c09b7ae3868250a
4
- data.tar.gz: 1c37ec6416541d255d5714e168357d1a0aa5e750d7cf32aef48f20bdf6439814
3
+ metadata.gz: f7f7a2045f7c3c436e4d3baeb78b64195f9512c88a2727faa679ac53115d8f17
4
+ data.tar.gz: 5f92c2a5bb410f6805f7a976a6ba64b42b8b79a4227b9c12c65471f017dfb93f
5
5
  SHA512:
6
- metadata.gz: 8efe1a7a8ef558760ebcdcd99c047863c1fbafdb59b97ddff81667a2ef081f3613795c6e01ad24b0a4b2e58f146ba8302f37986f3d6c2f9c02768eed9c6e7a22
7
- data.tar.gz: 7f40b04e14c5ee213997189150c8b50c91b4b07bbf01f632850046b80064b212fe324bb8beab8f47a7f0fdfb382b1da05f81e5fdf9b419d84b079b0b61309987
6
+ metadata.gz: 3b2d904c4dfe01a254043657f021fa210b5c4b962e58b800f8bef577d882a68bd66ccc6b204ee0b4a854f1760962e4fdadd77a3de8282bcb72940c5a37b04d2f
7
+ data.tar.gz: 20d8675fc875ca2bfcb609688fe20d1acd528f43bdb0624d4628ba5d3b7ae73833be58701deed845b312a1d29554176b4ebb19364b40e904bfff8464bf0c4c39
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1 - 2026-09-21
4
+
5
+ - Optimize large GIF quantization and frame-difference encoding.
6
+
3
7
  ## 0.1.0 - 2026-09-20
4
8
 
5
9
  - Initial release.
data/README.md CHANGED
@@ -1,25 +1,69 @@
1
- # Wezen
1
+ <h1 align="center">Wezen</h1>
2
2
 
3
- Wezen is named for δ Canis Majoris, whose IAU name comes from the Arabic
4
- *al-wazn* (“the weight”). It is a dependency-free Ruby encoder for deterministic
5
- demo media.
3
+ <p align="center">
4
+ <strong>Dependency-free Ruby encoders for deterministic APNG, GIF, PNG, and asciinema demo media.</strong>
5
+ </p>
6
+
7
+ <p align="center">
8
+ <a href="https://rubygems.org/gems/wezen"><img src="https://img.shields.io/gem/v/wezen.svg?color=b7a66f" alt="Gem version"></a>
9
+ <a href="https://github.com/noxdea/wezen/actions/workflows/main.yml"><img src="https://github.com/noxdea/wezen/actions/workflows/main.yml/badge.svg" alt="CI status"></a>
10
+ <img src="https://img.shields.io/badge/Ruby-3.1%2B-cc342d" alt="Ruby 3.1 or newer">
11
+ <a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-4c8eda" alt="MIT license"></a>
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="#features">Features</a> ·
16
+ <a href="#installation">Installation</a> ·
17
+ <a href="#quick-start">Quick start</a> ·
18
+ <a href="#output-formats">Output formats</a> ·
19
+ <a href="#development">Development</a>
20
+ </p>
21
+
22
+ ---
23
+
24
+ Wezen turns RGBA frames and terminal events into portable, reproducible demo
25
+ assets. It is built for documentation, fixtures, and recording pipelines where
26
+ the same input should produce the same bytes.
27
+
28
+ The name comes from δ Canis Majoris: its IAU name derives from the Arabic
29
+ *al-wazn*, meaning “the weight.”
6
30
 
7
31
  ## Features
8
32
 
9
- - Lossless APNG and PNG output with optional frame rectangles
10
- - GIF89a output with deterministic median-cut palettes and dithering
11
- - Pixel scaling, cropping, alpha blending, and change bounds
12
- - Asciinema v2 JSON Lines casts
33
+ - Lossless APNG with optional changed-frame rectangles, plus PNG stills
34
+ - GIF89a encoding with deterministic median-cut palettes and dithering
35
+ - Frame coalescing, scaling, cropping, sampling, blending, and change bounds
36
+ - Asciinema v2 JSON Lines output for terminal recordings
37
+ - Byte-stable output for identical inputs
13
38
  - No runtime dependencies beyond Ruby's `zlib` and `json` standard libraries
14
39
 
15
40
  ## Installation
16
41
 
42
+ Add Wezen to your Gemfile:
43
+
44
+ ```ruby
45
+ gem "wezen"
46
+ ```
47
+
48
+ Then install dependencies:
49
+
50
+ ```sh
51
+ bundle install
52
+ ```
53
+
54
+ Or install the gem directly:
55
+
17
56
  ```sh
18
57
  gem install wezen
19
58
  ```
20
59
 
60
+ Wezen requires Ruby 3.1 or newer.
61
+
21
62
  ## Quick start
22
63
 
64
+ Create a two-frame animation and write lossless APNG and compatible GIF
65
+ versions:
66
+
23
67
  ```ruby
24
68
  require "wezen"
25
69
 
@@ -31,22 +75,115 @@ Wezen::APNG.write("demo.apng", animation)
31
75
  Wezen::GIF.write("demo.gif", animation, dither: :bayer)
32
76
  ```
33
77
 
34
- `Animation` accepts RGBA strings in row-major order. Repeated frames are
35
- coalesced and their delays are added. Encoding the same animation twice yields
36
- the same bytes.
78
+ Frames are packed RGBA strings in row-major order. `Animation#add` merges
79
+ identical consecutive frames and adds their delays automatically.
80
+
81
+ ## Output formats
82
+
83
+ | Format | Use case | Behavior |
84
+ |---|---|---|
85
+ | APNG | Lossless animation | Preserves RGBA pixels and stores changed rectangles when possible |
86
+ | GIF89a | Broad compatibility | Uses a deterministic global palette with optional dithering |
87
+ | PNG | Still frames | Writes a single lossless RGBA image |
88
+ | Asciinema v2 | Terminal sessions | Writes chronological output, input, and resize events as JSON Lines |
89
+
90
+ ### APNG
91
+
92
+ APNG is the preferred animation format when color and alpha fidelity matter:
93
+
94
+ ```ruby
95
+ bytes = Wezen::APNG.encode(animation, compression: 9, diff: true)
96
+ File.binwrite("demo.apng", bytes)
97
+ ```
98
+
99
+ ### GIF
100
+
101
+ Control palette size, palette strategy, dithering, and frame differences:
102
+
103
+ ```ruby
104
+ Wezen::GIF.write(
105
+ "demo.gif",
106
+ animation,
107
+ colors: 128,
108
+ palette: :global,
109
+ dither: :floyd_steinberg,
110
+ diff: true
111
+ )
112
+ ```
113
+
114
+ Provide a `Wezen::Palette` when the recording needs a fixed application palette.
115
+ Supported dithering modes are `:floyd_steinberg`, `:bayer`, and `:none`.
116
+
117
+ ### PNG
118
+
119
+ Write a single packed RGBA frame:
120
+
121
+ ```ruby
122
+ frame = animation.each.first
123
+ Wezen::PNG.write("frame.png", animation.width, animation.height, frame.rgba)
124
+ ```
125
+
126
+ ### Asciinema casts
127
+
128
+ Create an asciinema v2 recording from chronological terminal events:
129
+
130
+ ```ruby
131
+ events = [
132
+ Wezen::Cast::Event.new(0.0, :output, "$ bundle exec rake\r\n"),
133
+ Wezen::Cast::Event.new(0.4, :output, "12 examples, 0 failures\r\n")
134
+ ]
135
+
136
+ Wezen::Cast.write(
137
+ "demo.cast",
138
+ width: 80,
139
+ height: 24,
140
+ title: "Test run",
141
+ events: events
142
+ )
143
+ ```
144
+
145
+ Event kinds are `:output`, `:input`, and `:resize`.
146
+
147
+ ## Transform animations
148
+
149
+ Every transformation returns a new `Wezen::Animation`:
150
+
151
+ ```ruby
152
+ animation = animation.coalesce(tolerance: 2)
153
+ animation = animation.scale(to_width: 640, to_height: 360)
154
+ animation = animation.crop([0, 0, 320, 180])
155
+ animation = animation.drop_to(fps: 12)
156
+ ```
157
+
158
+ Use `Wezen::Image` directly for lower-level scaling, cropping, alpha blending,
159
+ and changed-pixel bounds.
160
+
161
+ ## Design decisions
162
+
163
+ Wezen deliberately ships encoders without runtime decoders. This keeps the gem
164
+ small, portable, and focused while round-trip tests validate generated media.
165
+
166
+ The architecture records explain the durable format choices:
167
+
168
+ - [Encode-only scope](docs/adr/001-encode-only.md)
169
+ - [Deterministic global palette](docs/adr/002-global-palette.md)
170
+ - [APNG as the default animation format](docs/adr/003-apng-default.md)
37
171
 
38
172
  ## Development
39
173
 
40
174
  ```sh
41
175
  bundle install
42
176
  bundle exec rake
177
+ bundle exec rake bench
43
178
  bundle exec rbs -I sig validate
44
179
  gem build --strict wezen.gemspec
45
180
  ```
46
181
 
47
- The runtime gem intentionally contains encoders only; decoder helpers belong in
48
- tests so the package stays small and dependency-free.
182
+ ## Contributing
183
+
184
+ Bug reports and pull requests are welcome in the
185
+ [GitHub repository](https://github.com/noxdea/wezen).
49
186
 
50
187
  ## License
51
188
 
52
- MIT. See [LICENSE.txt](LICENSE.txt).
189
+ Wezen is available under the [MIT License](LICENSE.txt).
data/lib/wezen/gif.rb CHANGED
@@ -24,18 +24,25 @@ module Wezen
24
24
 
25
25
  previous = nil
26
26
  animation.each.with_index do |frame, index|
27
- indices = Quantize.apply_with_size(frame.rgba, source_palette, width: animation.width, height: animation.height, dither: dither)
28
- rect, patch = if index.zero? || !diff || previous.nil?
29
- [[0, 0, animation.width, animation.height], indices]
27
+ rect, patch_rgba = if index.zero? || !diff || previous.nil?
28
+ [[0, 0, animation.width, animation.height], frame.rgba]
30
29
  else
31
30
  bounds = Image.diff_bounds(previous, frame.rgba, animation.width, animation.height)
32
31
  if bounds
33
- [bounds, indexed_crop(indices, animation.width, bounds)]
32
+ [bounds, Image.crop(frame.rgba, animation.width, animation.height, bounds)]
34
33
  else
35
- [[0, 0, 1, 1], source_palette.transparent_index.chr]
34
+ [[0, 0, 1, 1], nil]
36
35
  end
37
36
  end
38
- transparent = source_palette.transparent_index && frame.rgba.bytes.each_slice(4).any? { |pixel| pixel[3] < 128 }
37
+ patch = if patch_rgba
38
+ width = rect[2]
39
+ height = rect[3]
40
+ transparent = source_palette.transparent_index && patch_rgba.bytes.each_slice(4).any? { |pixel| pixel[3] < 128 }
41
+ Quantize.apply_with_size(patch_rgba, source_palette, width: width, height: height, dither: dither)
42
+ else
43
+ transparent = source_palette.transparent_index
44
+ patch_rgba
45
+ end
39
46
  output << graphic_control(frame.delay_ms, transparent ? source_palette.transparent_index : nil)
40
47
  output << image_descriptor(rect, bits)
41
48
  output << sub_blocks(lzw(patch, [bits, 2].max), [bits, 2].max)
@@ -69,13 +76,6 @@ module Wezen
69
76
  [44, x, y, width, height, 0].pack("CvvvvC")
70
77
  end
71
78
 
72
- def indexed_crop(indices, width, rect)
73
- x, y, crop_width, crop_height = rect
74
- output = String.new(capacity: crop_width * crop_height, encoding: Encoding::BINARY)
75
- crop_height.times { |row| output << indices.byteslice((y + row) * width + x, crop_width) }
76
- output
77
- end
78
-
79
79
  def sub_blocks(bytes, minimum_code_size)
80
80
  output = minimum_code_size.chr
81
81
  bytes.bytes.each_slice(255) { |slice| output << slice.length.chr << slice.pack("C*") }
@@ -105,7 +105,7 @@ module Wezen
105
105
  if next_code < 4096
106
106
  dictionary[key] = next_code
107
107
  next_code += 1
108
- code_size += 1 if next_code == (1 << code_size) && code_size < 12
108
+ code_size += 1 if next_code > (1 << code_size) && code_size < 12
109
109
  else
110
110
  writer.write(clear, code_size)
111
111
  dictionary.clear
@@ -143,6 +143,6 @@ module Wezen
143
143
  end
144
144
 
145
145
  private_class_method :reserve_transparency, :loop_extension, :graphic_control, :image_descriptor,
146
- :indexed_crop, :sub_blocks, :lzw
146
+ :sub_blocks, :lzw
147
147
  end
148
148
  end
data/lib/wezen/image.rb CHANGED
@@ -81,10 +81,21 @@ module Wezen
81
81
  def diff_bounds(previous, current, width, height)
82
82
  validate!(previous, width, height); validate!(current, width, height)
83
83
  min_x = width; min_y = height; max_x = -1; max_y = -1
84
- (0...(width * height)).each do |index|
85
- next if previous.byteslice(index * 4, 4) == current.byteslice(index * 4, 4)
86
- x = index % width; y = index / width
87
- min_x = x if x < min_x; min_y = y if y < min_y; max_x = x if x > max_x; max_y = y if y > max_y
84
+ row_bytes = width * 4
85
+ height.times do |y|
86
+ row_offset = y * row_bytes
87
+ next if previous.byteslice(row_offset, row_bytes) == current.byteslice(row_offset, row_bytes)
88
+
89
+ width.times do |x|
90
+ offset = row_offset + x * 4
91
+ different = previous.getbyte(offset) != current.getbyte(offset) ||
92
+ previous.getbyte(offset + 1) != current.getbyte(offset + 1) ||
93
+ previous.getbyte(offset + 2) != current.getbyte(offset + 2) ||
94
+ previous.getbyte(offset + 3) != current.getbyte(offset + 3)
95
+ if different
96
+ min_x = x if x < min_x; min_y = y if y < min_y; max_x = x if x > max_x; max_y = y if y > max_y
97
+ end
98
+ end
88
99
  end
89
100
  max_x.negative? ? nil : [min_x, min_y, max_x - min_x + 1, max_y - min_y + 1]
90
101
  end
@@ -4,13 +4,20 @@ module Wezen
4
4
  module Quantize
5
5
  module_function
6
6
 
7
+ MAX_PALETTE_SAMPLES = 32_768
8
+
7
9
  def palette(animation, colors:, method: :median_cut)
8
10
  raise ArgumentError, "unsupported quantizer: #{method}" unless %i[median_cut octree].include?(method)
9
11
  limit = Integer(colors)
10
12
  raise ArgumentError, "colors must be between 2 and 256" unless limit.between?(2, 256)
11
- pixels = animation.each.flat_map { |frame| frame.rgba.bytes.each_slice(4).reject { |pixel| pixel[3] < 128 }.map { |pixel| pixel[0, 3] } }
13
+ total_pixels = animation.width * animation.height * animation.size
14
+ pixels, transparent = if total_pixels <= MAX_PALETTE_SAMPLES
15
+ [animation.each.flat_map { |frame| frame.rgba.bytes.each_slice(4).reject { |pixel| pixel[3] < 128 }.map { |pixel| pixel[0, 3] } },
16
+ animation.each.any? { |frame| frame.rgba.bytes.each_slice(4).any? { |pixel| pixel[3] < 128 } }]
17
+ else
18
+ sample_pixels(animation, total_pixels)
19
+ end
12
20
  pixels = [[0, 0, 0]] if pixels.empty?
13
- transparent = animation.each.any? { |frame| frame.rgba.bytes.each_slice(4).any? { |pixel| pixel[3] < 128 } }
14
21
  opaque_limit = transparent ? limit - 1 : limit
15
22
  colors_out = median_cut(pixels, opaque_limit)
16
23
  if transparent
@@ -21,6 +28,30 @@ module Wezen
21
28
  end
22
29
  end
23
30
 
31
+ def sample_pixels(animation, total_pixels)
32
+ # ponytail: cap median-cut input at 32K samples; replace with a weighted histogram if palette fidelity matters.
33
+ step = (total_pixels.fdiv(MAX_PALETTE_SAMPLES)).ceil
34
+ pixels = []
35
+ transparent = false
36
+ pixel_index = 0
37
+ next_sample = 0
38
+ animation.each do |frame|
39
+ rgba = frame.rgba
40
+ offset = 0
41
+ while offset < rgba.bytesize
42
+ alpha = rgba.getbyte(offset + 3)
43
+ transparent ||= alpha < 128
44
+ if pixel_index == next_sample
45
+ pixels << [rgba.getbyte(offset), rgba.getbyte(offset + 1), rgba.getbyte(offset + 2)] if alpha >= 128
46
+ next_sample += step
47
+ end
48
+ pixel_index += 1
49
+ offset += 4
50
+ end
51
+ end
52
+ [pixels, transparent]
53
+ end
54
+
24
55
  def apply(rgba, palette, width: nil, height: nil, dither: :floyd_steinberg)
25
56
  raise ArgumentError, "unknown dither: #{dither}" unless %i[none bayer floyd_steinberg].include?(dither)
26
57
  raise ArgumentError, "palette must be a Wezen::Palette" unless palette.respond_to?(:colors)
@@ -30,7 +61,11 @@ module Wezen
30
61
  end
31
62
 
32
63
  def apply_with_size(rgba, palette, width:, height:, dither: :floyd_steinberg)
33
- raise ArgumentError, "invalid RGBA pixels" unless rgba.bytesize == Integer(width) * Integer(height) * 4
64
+ width = Integer(width)
65
+ height = Integer(height)
66
+ raise ArgumentError, "invalid RGBA pixels" unless rgba.bytesize == width * height * 4
67
+ return apply_without_error(rgba, palette, width, height, dither) unless dither == :floyd_steinberg
68
+
34
69
  colors = palette.colors
35
70
  output = String.new(capacity: width * height, encoding: Encoding::BINARY)
36
71
  errors = Array.new(width * 3, 0.0)
@@ -65,6 +100,48 @@ module Wezen
65
100
  output
66
101
  end
67
102
 
103
+ def apply_without_error(rgba, palette, width, height, dither)
104
+ colors = palette.colors
105
+ transparent_index = palette.transparent_index
106
+ output = String.new(capacity: width * height, encoding: Encoding::BINARY)
107
+ cache = {}
108
+ offset = 0
109
+ x = 0
110
+ y = 0
111
+ while offset < rgba.bytesize
112
+ alpha = rgba.getbyte(offset + 3)
113
+ if alpha < 128 && transparent_index
114
+ output << transparent_index
115
+ else
116
+ red = rgba.getbyte(offset)
117
+ green = rgba.getbyte(offset + 1)
118
+ blue = rgba.getbyte(offset + 2)
119
+ if dither == :bayer
120
+ adjustment = (bayer(x, y) * 16 - 8).to_i
121
+ red = red + adjustment
122
+ green = green + adjustment
123
+ blue = blue + adjustment
124
+ red = 0 if red < 0
125
+ green = 0 if green < 0
126
+ blue = 0 if blue < 0
127
+ red = 255 if red > 255
128
+ green = 255 if green > 255
129
+ blue = 255 if blue > 255
130
+ end
131
+ key = (red << 16) | (green << 8) | blue
132
+ cache[key] ||= nearest_rgb(red, green, blue, colors, transparent_index)
133
+ output << cache[key]
134
+ end
135
+ offset += 4
136
+ x += 1
137
+ if x == width
138
+ x = 0
139
+ y += 1
140
+ end
141
+ end
142
+ output
143
+ end
144
+
68
145
  def median_cut(pixels, limit)
69
146
  boxes = [pixels.sort_by { |pixel| pixel[0] * 65_536 + pixel[1] * 256 + pixel[2] }]
70
147
  while boxes.length < limit
@@ -87,9 +164,22 @@ module Wezen
87
164
  end
88
165
 
89
166
  def nearest(pixel, colors, transparent_index)
90
- colors.each_with_index.reject { |_color, index| index == transparent_index }.min_by do |color, index|
91
- [pixel.each_with_index.sum { |value, channel| (value - color[channel])**2 }, index]
92
- end.last
167
+ nearest_rgb(pixel[0], pixel[1], pixel[2], colors, transparent_index)
168
+ end
169
+
170
+ def nearest_rgb(red, green, blue, colors, transparent_index)
171
+ best_index = nil
172
+ best_distance = nil
173
+ colors.each_with_index do |color, index|
174
+ next if index == transparent_index
175
+
176
+ distance = (red - color[0])**2 + (green - color[1])**2 + (blue - color[2])**2
177
+ if best_distance.nil? || distance < best_distance
178
+ best_distance = distance
179
+ best_index = index
180
+ end
181
+ end
182
+ best_index
93
183
  end
94
184
 
95
185
  def bayer(x, y)
@@ -103,6 +193,6 @@ module Wezen
103
193
  add.call(next_errors, x, 5.0 / 16)
104
194
  add.call(next_errors, x + 1, 1.0 / 16)
105
195
  end
106
- private_class_method :median_cut, :nearest, :bayer, :distribute
196
+ private_class_method :sample_pixels, :median_cut, :nearest, :nearest_rgb, :apply_without_error, :bayer, :distribute
107
197
  end
108
198
  end
data/lib/wezen/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wezen
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wezen
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-09-20 00:00:00.000000000 Z
11
12
  dependencies: []
12
13
  description: Pure Ruby encoders and pixel primitives for deterministic demo recordings.
13
14
  email:
@@ -44,6 +45,7 @@ metadata:
44
45
  source_code_uri: https://github.com/noxdea/wezen/tree/main
45
46
  changelog_uri: https://github.com/noxdea/wezen/blob/main/CHANGELOG.md
46
47
  rubygems_mfa_required: 'true'
48
+ post_install_message:
47
49
  rdoc_options: []
48
50
  require_paths:
49
51
  - lib
@@ -58,7 +60,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
58
60
  - !ruby/object:Gem::Version
59
61
  version: '0'
60
62
  requirements: []
61
- rubygems_version: 4.0.16
63
+ rubygems_version: 3.4.19
64
+ signing_key:
62
65
  specification_version: 4
63
66
  summary: Deterministic GIF, APNG, PNG, and asciinema encoders for Ruby
64
67
  test_files: []