wezen 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 371481b9c7234ab5048aeb49b4ee4413c71aee57c46331130c09b7ae3868250a
4
+ data.tar.gz: 1c37ec6416541d255d5714e168357d1a0aa5e750d7cf32aef48f20bdf6439814
5
+ SHA512:
6
+ metadata.gz: 8efe1a7a8ef558760ebcdcd99c047863c1fbafdb59b97ddff81667a2ef081f3613795c6e01ad24b0a4b2e58f146ba8302f37986f3d6c2f9c02768eed9c6e7a22
7
+ data.tar.gz: 7f40b04e14c5ee213997189150c8b50c91b4b07bbf01f632850046b80064b212fe324bb8beab8f47a7f0fdfb382b1da05f81e5fdf9b419d84b079b0b61309987
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-09-20
4
+
5
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # Wezen
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.
6
+
7
+ ## Features
8
+
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
13
+ - No runtime dependencies beyond Ruby's `zlib` and `json` standard libraries
14
+
15
+ ## Installation
16
+
17
+ ```sh
18
+ gem install wezen
19
+ ```
20
+
21
+ ## Quick start
22
+
23
+ ```ruby
24
+ require "wezen"
25
+
26
+ animation = Wezen::Animation.new(width: 2, height: 1)
27
+ animation.add([255, 0, 0, 255, 0, 0, 255, 255].pack("C*"), delay_ms: 100)
28
+ animation.add([255, 0, 0, 255, 0, 255, 0, 255].pack("C*"), delay_ms: 100)
29
+
30
+ Wezen::APNG.write("demo.apng", animation)
31
+ Wezen::GIF.write("demo.gif", animation, dither: :bayer)
32
+ ```
33
+
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.
37
+
38
+ ## Development
39
+
40
+ ```sh
41
+ bundle install
42
+ bundle exec rake
43
+ bundle exec rbs -I sig validate
44
+ gem build --strict wezen.gemspec
45
+ ```
46
+
47
+ The runtime gem intentionally contains encoders only; decoder helpers belong in
48
+ tests so the package stays small and dependency-free.
49
+
50
+ ## License
51
+
52
+ MIT. See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,24 @@
1
+ # ADR NNN: Implementation decision title
2
+
3
+ - Status: Proposed
4
+ - Date: YYYY-MM-DD
5
+
6
+ ## Context
7
+
8
+ Describe the concrete implementation question and its compatibility, data,
9
+ runtime, or component constraints. Operational workflow and release policy do
10
+ not belong in this directory; see [the scope](README.md).
11
+
12
+ Name the credible alternatives. If there was no meaningful alternative, record
13
+ the behavior in reference documentation instead of creating an ADR.
14
+
15
+ ## Decision
16
+
17
+ Describe the durable boundary or architecture choice. Leave command syntax,
18
+ field-by-field formats, test evidence, benchmark results, and migration steps
19
+ in their authoritative documents.
20
+
21
+ ## Consequences
22
+
23
+ Describe the important positive and negative trade-offs, including what would
24
+ make this decision worth revisiting.
@@ -0,0 +1,26 @@
1
+ # ADR 001: Encode-only scope
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-20
5
+
6
+ ## Context
7
+
8
+ Wezen is a small, dependency-free library for turning frames and terminal
9
+ events into portable media. Adding decoders would increase the runtime surface
10
+ and introduce format-specific behavior that callers do not need to produce
11
+ media.
12
+
13
+ The credible alternatives were to ship decoders with the gem, depend on an
14
+ external image library, or keep decoding support in tests only.
15
+
16
+ ## Decision
17
+
18
+ Wezen writes deterministic APNG, GIF, PNG, and asciinema streams. Decoders
19
+ remain test support and are not part of the runtime gem.
20
+
21
+ ## Consequences
22
+
23
+ The runtime remains small, portable, and dependency-free, while round-trip
24
+ tests still validate the encoders. Callers that need to inspect or decode
25
+ media must provide that capability separately; revisit this decision only if
26
+ encoding users also require a stable decoding API.
@@ -0,0 +1,27 @@
1
+ # ADR 002: Deterministic global palette
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-20
5
+
6
+ ## Context
7
+
8
+ GIF has a single global color table in the common output path, while recorded
9
+ animations may contain more colors than that table can hold. Per-frame
10
+ palettes could preserve local colors better, but make frame differences and
11
+ output size less predictable.
12
+
13
+ The credible alternatives were a per-frame palette, a fixed application
14
+ palette, or one deterministic palette for the complete animation.
15
+
16
+ ## Decision
17
+
18
+ GIF uses one median-cut palette for the animation. Palette axes, color ordering,
19
+ and tie-breaking are stable so identical inputs produce byte-identical output.
20
+
21
+ ## Consequences
22
+
23
+ Palette construction is reproducible and shared across frames, which keeps
24
+ GIF output simple and suitable for size budgets. A global palette can represent
25
+ some local gradients less accurately than a per-frame palette; revisit this if
26
+ measured media quality requires adaptive palettes without compromising
27
+ determinism.
@@ -0,0 +1,26 @@
1
+ # ADR 003: APNG is the default
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-20
5
+
6
+ ## Context
7
+
8
+ Recorded UI frames commonly use transparency and more than 256 colors. GIF is
9
+ widely supported but requires palette quantization and has limited alpha
10
+ semantics. APNG preserves the source pixel format while remaining a portable
11
+ single-file animation.
12
+
13
+ The credible alternatives were GIF as the only output, a sequence of PNG
14
+ files, or APNG as the lossless default with GIF for compatibility.
15
+
16
+ ## Decision
17
+
18
+ APNG is the default animation output and preserves RGBA pixels. GIF remains an
19
+ explicit compatibility output with deterministic palette quantization.
20
+
21
+ ## Consequences
22
+
23
+ The default path keeps colors and transparency intact, while callers targeting
24
+ GIF-only viewers can opt in to the documented quantization behavior. APNG
25
+ support is not universal in every legacy viewer; revisit the default only if
26
+ the supported playback targets change.
@@ -0,0 +1,10 @@
1
+ # Architecture decision records
2
+
3
+ These records document durable implementation boundaries and choices. Commands,
4
+ formats, benchmarks, and release steps belong in the README or their
5
+ implementation documents.
6
+
7
+ - [ADR template](000-template.md)
8
+ - [ADR 001: Encode-only scope](001-encode-only.md)
9
+ - [ADR 002: Deterministic global palette](002-global-palette.md)
10
+ - [ADR 003: APNG is the default](003-apng-default.md)
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wezen
4
+ Frame = Data.define(:rgba, :delay_ms)
5
+
6
+ class Animation
7
+ include Enumerable
8
+
9
+ attr_reader :width, :height, :loop
10
+
11
+ def initialize(width:, height:, loop: 0)
12
+ @width = Integer(width)
13
+ @height = Integer(height)
14
+ @loop = Integer(loop)
15
+ raise ArgumentError, "invalid animation dimensions" unless @width.positive? && @height.positive?
16
+ raise ArgumentError, "loop must be non-negative" if @loop.negative?
17
+
18
+ @frames = []
19
+ end
20
+
21
+ def add(rgba, delay_ms:)
22
+ pixels = String(rgba).b
23
+ delay = Integer(delay_ms)
24
+ raise ArgumentError, "invalid RGBA frame" unless pixels.bytesize == @width * @height * 4
25
+ raise ArgumentError, "delay_ms must be non-negative" if delay.negative?
26
+
27
+ if (last = @frames.last) && last.rgba == pixels
28
+ @frames[-1] = Frame.new(last.rgba, last.delay_ms + delay)
29
+ else
30
+ @frames << Frame.new(pixels.freeze, delay)
31
+ end
32
+ self
33
+ end
34
+
35
+ def <<(frame)
36
+ raise ArgumentError, "expected Wezen::Frame" unless frame.respond_to?(:rgba) && frame.respond_to?(:delay_ms)
37
+
38
+ add(frame.rgba, delay_ms: frame.delay_ms)
39
+ end
40
+
41
+ def size = @frames.size
42
+ def duration_ms = @frames.sum(&:delay_ms)
43
+ def each(&block) = block ? @frames.each(&block) : @frames.each
44
+
45
+ def coalesce(tolerance: 0)
46
+ tolerance = Integer(tolerance)
47
+ raise ArgumentError, "tolerance must be non-negative" if tolerance.negative?
48
+
49
+ result = self.class.new(width: @width, height: @height, loop: @loop)
50
+ @frames.each do |frame|
51
+ frames = result.instance_variable_get(:@frames)
52
+ if (last = frames.last) && same?(last.rgba, frame.rgba, tolerance)
53
+ frames[-1] = Frame.new(last.rgba, last.delay_ms + frame.delay_ms)
54
+ else
55
+ result << frame
56
+ end
57
+ end
58
+ result
59
+ end
60
+
61
+ def scale(to_width:, to_height:)
62
+ result = self.class.new(width: to_width, height: to_height, loop: @loop)
63
+ each { |frame| result.add(Image.scale(frame.rgba, @width, @height, to_width: to_width, to_height: to_height), delay_ms: frame.delay_ms) }
64
+ result
65
+ end
66
+
67
+ def crop(bounds)
68
+ x, y, width, height = Image.bounds(bounds)
69
+ result = self.class.new(width: width, height: height, loop: @loop)
70
+ each { |frame| result.add(Image.crop(frame.rgba, @width, @height, [x, y, width, height]), delay_ms: frame.delay_ms) }
71
+ result
72
+ end
73
+
74
+ def drop_to(fps:)
75
+ fps = Float(fps)
76
+ raise ArgumentError, "fps must be positive" unless fps.positive?
77
+ interval = 1000.0 / fps
78
+ result = self.class.new(width: @width, height: @height, loop: @loop)
79
+ elapsed = 0.0
80
+ next_sample = 0.0
81
+ each do |frame|
82
+ frame_start = elapsed
83
+ frame_end = elapsed + frame.delay_ms
84
+ while next_sample < frame_end || (result.size.zero? && next_sample == frame_start)
85
+ result.add(frame.rgba, delay_ms: interval.round)
86
+ next_sample += interval
87
+ end
88
+ elapsed = frame_end
89
+ end
90
+ result
91
+ end
92
+
93
+ private
94
+
95
+ def same?(left, right, tolerance)
96
+ return true if left == right
97
+ return false unless tolerance.positive? && left.bytesize == right.bytesize
98
+
99
+ left.bytes.zip(right.bytes).all? { |a, b| (a - b).abs <= tolerance }
100
+ end
101
+ end
102
+ end
data/lib/wezen/apng.rb ADDED
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wezen
4
+ module APNG
5
+ module_function
6
+
7
+ def encode(animation, compression: 9, diff: true)
8
+ raise ArgumentError, "animation must contain at least one frame" if animation.size.zero?
9
+ frames = animation.each.to_a
10
+ sequence = 0
11
+ output = PNG::SIGNATURE.dup
12
+ output << PNG.chunk("IHDR", [animation.width, animation.height, 8, 6, 0, 0, 0].pack("NNC5"))
13
+ output << PNG.chunk("acTL", [frames.length, animation.loop].pack("N2"))
14
+ previous = nil
15
+ frames.each_with_index do |frame, index|
16
+ rect, pixels, blend = rectangle(animation, previous, frame.rgba, diff && index.positive?)
17
+ delay_num, delay_den = delay(frame.delay_ms)
18
+ output << PNG.chunk("fcTL", [sequence, rect[2], rect[3], rect[0], rect[1], delay_num, delay_den, 0, blend].pack("N5n2C2"))
19
+ sequence += 1
20
+ scanlines = scanlines(pixels, rect[2], rect[3])
21
+ compressed = Zlib::Deflate.deflate(scanlines, Integer(compression))
22
+ if index.zero?
23
+ output << PNG.chunk("IDAT", compressed)
24
+ else
25
+ output << PNG.chunk("fdAT", [sequence].pack("N") + compressed)
26
+ sequence += 1
27
+ end
28
+ previous = frame.rgba
29
+ end
30
+ output << PNG.chunk("IEND", "".b)
31
+ end
32
+
33
+ def write(path, animation, **options)
34
+ File.binwrite(path, encode(animation, **options))
35
+ path
36
+ end
37
+
38
+ def rectangle(animation, previous, current, use_diff)
39
+ return [[0, 0, animation.width, animation.height], current, 0] unless use_diff && previous
40
+
41
+ bounds = Image.diff_bounds(previous, current, animation.width, animation.height)
42
+ return [[0, 0, 1, 1], "\0\0\0\0".b, 1] unless bounds
43
+
44
+ x, y, width, height = bounds
45
+ patch = Image.crop(current, animation.width, animation.height, bounds)
46
+ # APNG source blending is exact for the opaque UI pixels this encoder targets.
47
+ # Fall back to a complete frame when alpha would make source-over lossy.
48
+ if patch.bytes.each_slice(4).any? { |pixel| pixel[3] != 255 }
49
+ [[0, 0, animation.width, animation.height], current, 0]
50
+ else
51
+ [[x, y, width, height], patch, 1]
52
+ end
53
+ end
54
+
55
+ def scanlines(rgba, width, height)
56
+ raw = String.new(encoding: Encoding::BINARY)
57
+ height.times { |row| raw << "\0" << rgba.byteslice(row * width * 4, width * 4) }
58
+ raw
59
+ end
60
+
61
+ def delay(milliseconds)
62
+ milliseconds = Integer(milliseconds)
63
+ return [0, 1000] if milliseconds.zero?
64
+ denominator = 1000
65
+ numerator = milliseconds
66
+ common = numerator.gcd(denominator)
67
+ [numerator / common, denominator / common]
68
+ end
69
+ private_class_method :rectangle, :scanlines, :delay
70
+ end
71
+ end
data/lib/wezen/cast.rb ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Wezen
6
+ module Cast
7
+ Event = Data.define(:time, :kind, :data)
8
+
9
+ module_function
10
+
11
+ def encode(width:, height:, events:, title: nil, env: {})
12
+ width = Integer(width); height = Integer(height)
13
+ raise ArgumentError, "invalid terminal dimensions" unless width.positive? && height.positive?
14
+ header = { "version" => 2, "width" => width, "height" => height, "timestamp" => 0, "env" => env.transform_keys(&:to_s).transform_values(&:to_s) }
15
+ header["title"] = title.to_s if title
16
+ lines = [JSON.generate(header)]
17
+ last_time = 0.0
18
+ Array(events).each do |event|
19
+ event = event.is_a?(Event) ? event : Event.new(*event)
20
+ event = Event.new(Float(event.time), event.kind.to_sym, event.data.to_s)
21
+ raise ArgumentError, "cast events must be chronological" if event.time < last_time
22
+ raise ArgumentError, "event time must be non-negative" if event.time.negative?
23
+ raise ArgumentError, "unknown cast event: #{event.kind}" unless %i[output input resize].include?(event.kind)
24
+ kind = { output: "o", input: "i", resize: "r" }.fetch(event.kind)
25
+ lines << JSON.generate([event.time, kind, event.data])
26
+ last_time = event.time
27
+ end
28
+ lines.join("\n") + "\n"
29
+ end
30
+
31
+ def write(path, **options)
32
+ File.write(path, encode(**options), mode: "wb")
33
+ path
34
+ end
35
+ end
36
+ end
data/lib/wezen/gif.rb ADDED
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wezen
4
+ module GIF
5
+ module_function
6
+
7
+ def encode(animation, colors: 256, palette: :global, dither: :floyd_steinberg, diff: true)
8
+ raise ArgumentError, "animation must contain at least one frame" if animation.size.zero?
9
+ raise ArgumentError, "palette must be :global, :adaptive, or a Palette" unless palette == :global || palette == :adaptive || palette.respond_to?(:colors)
10
+ limit = Integer(colors)
11
+ raise ArgumentError, "colors must be between 2 and 256" unless limit.between?(2, 256)
12
+
13
+ source_palette = palette.respond_to?(:colors) ? palette : Quantize.palette(animation, colors: limit)
14
+ needs_transparency = diff && animation.each_cons(2).any? { |left, right| left.rgba == right.rgba }
15
+ source_palette = reserve_transparency(source_palette, limit) if needs_transparency && source_palette.transparent_index.nil?
16
+ table_size = [source_palette.colors.length, 2].max
17
+ bits = [[Math.log2(table_size).ceil, 1].max, 8].min
18
+ table_size = 1 << bits
19
+ colors_table = source_palette.colors + Array.new(table_size - source_palette.colors.length, [0, 0, 0])
20
+ output = "GIF89a".b
21
+ output << [animation.width, animation.height, 0x80 | ((bits - 1) << 4) | (bits - 1), 0, 0].pack("vvC3")
22
+ colors_table.each { |color| output << color.pack("C3") }
23
+ output << loop_extension(animation.loop)
24
+
25
+ previous = nil
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]
30
+ else
31
+ bounds = Image.diff_bounds(previous, frame.rgba, animation.width, animation.height)
32
+ if bounds
33
+ [bounds, indexed_crop(indices, animation.width, bounds)]
34
+ else
35
+ [[0, 0, 1, 1], source_palette.transparent_index.chr]
36
+ end
37
+ end
38
+ transparent = source_palette.transparent_index && frame.rgba.bytes.each_slice(4).any? { |pixel| pixel[3] < 128 }
39
+ output << graphic_control(frame.delay_ms, transparent ? source_palette.transparent_index : nil)
40
+ output << image_descriptor(rect, bits)
41
+ output << sub_blocks(lzw(patch, [bits, 2].max), [bits, 2].max)
42
+ previous = frame.rgba
43
+ end
44
+ output << "\x3B".b
45
+ end
46
+
47
+ def write(path, animation, **options)
48
+ File.binwrite(path, encode(animation, **options))
49
+ path
50
+ end
51
+
52
+ def reserve_transparency(palette, limit)
53
+ opaque = palette.colors.reject.with_index { |_color, index| index == palette.transparent_index }
54
+ Palette.new([[0, 0, 0], *opaque.first(limit - 1)], 0)
55
+ end
56
+
57
+ def loop_extension(loop)
58
+ "!\xFF\x0BNETSCAPE2.0\x03\x01".b + [loop].pack("v") + "\0".b
59
+ end
60
+
61
+ def graphic_control(delay_ms, transparent_index)
62
+ delay = [[(Integer(delay_ms) / 10.0).round, 0].max, 65_535].min
63
+ packed = transparent_index ? 1 : 0
64
+ "!\xF9\x04".b + [packed, delay, transparent_index || 0].pack("CvC") + "\0".b
65
+ end
66
+
67
+ def image_descriptor(rect, bits)
68
+ x, y, width, height = rect
69
+ [44, x, y, width, height, 0].pack("CvvvvC")
70
+ end
71
+
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
+ def sub_blocks(bytes, minimum_code_size)
80
+ output = minimum_code_size.chr
81
+ bytes.bytes.each_slice(255) { |slice| output << slice.length.chr << slice.pack("C*") }
82
+ output << "\0".b
83
+ end
84
+
85
+ def lzw(indices, minimum_code_size)
86
+ clear = 1 << minimum_code_size
87
+ ending = clear + 1
88
+ dictionary = {}
89
+ next_code = ending + 1
90
+ code_size = minimum_code_size + 1
91
+ writer = BitWriter.new
92
+ writer.write(clear, code_size)
93
+ prefix = nil
94
+ indices.bytes.each do |value|
95
+ if prefix.nil?
96
+ prefix = value
97
+ next
98
+ end
99
+ key = [prefix, value]
100
+ if dictionary.key?(key)
101
+ prefix = dictionary[key]
102
+ next
103
+ end
104
+ writer.write(prefix, code_size)
105
+ if next_code < 4096
106
+ dictionary[key] = next_code
107
+ next_code += 1
108
+ code_size += 1 if next_code == (1 << code_size) && code_size < 12
109
+ else
110
+ writer.write(clear, code_size)
111
+ dictionary.clear
112
+ next_code = ending + 1
113
+ code_size = minimum_code_size + 1
114
+ end
115
+ prefix = value
116
+ end
117
+ writer.write(prefix, code_size) unless prefix.nil?
118
+ writer.write(ending, code_size)
119
+ writer.finish
120
+ end
121
+
122
+ class BitWriter
123
+ def initialize
124
+ @bytes = String.new(encoding: Encoding::BINARY)
125
+ @buffer = 0
126
+ @bits = 0
127
+ end
128
+
129
+ def write(value, width)
130
+ @buffer |= value << @bits
131
+ @bits += width
132
+ while @bits >= 8
133
+ @bytes << (@buffer & 255)
134
+ @buffer >>= 8
135
+ @bits -= 8
136
+ end
137
+ end
138
+
139
+ def finish
140
+ @bytes << (@buffer & 255) if @bits.positive?
141
+ @bytes
142
+ end
143
+ end
144
+
145
+ private_class_method :reserve_transparency, :loop_extension, :graphic_control, :image_descriptor,
146
+ :indexed_crop, :sub_blocks, :lzw
147
+ end
148
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wezen
4
+ module Image
5
+ module_function
6
+
7
+ def scale(rgba, width, height, to_width:, to_height:)
8
+ width = Integer(width); height = Integer(height)
9
+ to_width = Integer(to_width); to_height = Integer(to_height)
10
+ validate!(rgba, width, height)
11
+ raise ArgumentError, "invalid target dimensions" unless to_width.positive? && to_height.positive?
12
+
13
+ output = String.new(capacity: to_width * to_height * 4, encoding: Encoding::BINARY)
14
+ to_height.times do |dy|
15
+ y0 = dy * height.fdiv(to_height); y1 = (dy + 1) * height.fdiv(to_height)
16
+ to_width.times do |dx|
17
+ x0 = dx * width.fdiv(to_width); x1 = (dx + 1) * width.fdiv(to_width)
18
+ sx0 = x0.floor; sx1 = [x1.ceil, width].min
19
+ sy0 = y0.floor; sy1 = [y1.ceil, height].min
20
+ count = 0; sum = [0, 0, 0, 0]
21
+ (sy0...sy1).each do |sy|
22
+ (sx0...sx1).each do |sx|
23
+ pixel = rgba.byteslice((sy * width + sx) * 4, 4).bytes
24
+ 4.times { |channel| sum[channel] += pixel[channel] }
25
+ count += 1
26
+ end
27
+ end
28
+ output << sum.map { |value| (value.to_f / count).round }.pack("C4")
29
+ end
30
+ end
31
+ output
32
+ end
33
+
34
+ def crop(rgba, width, height, bounds)
35
+ x, y, crop_width, crop_height = self.bounds(bounds)
36
+ validate!(rgba, width, height)
37
+ raise ArgumentError, "crop is outside image" unless x >= 0 && y >= 0 && crop_width.positive? && crop_height.positive? && x + crop_width <= width && y + crop_height <= height
38
+
39
+ output = String.new(capacity: crop_width * crop_height * 4, encoding: Encoding::BINARY)
40
+ crop_height.times { |row| output << rgba.byteslice(((y + row) * width + x) * 4, crop_width * 4) }
41
+ output
42
+ end
43
+
44
+ def blend(dst, dst_width, src, src_width, src_height, x:, y:, opacity: 1.0)
45
+ dst_width = Integer(dst_width); src_width = Integer(src_width); src_height = Integer(src_height)
46
+ destination = String(dst).b.dup
47
+ raise ArgumentError, "invalid source image" unless src.bytesize == src_width * src_height * 4
48
+ raise ArgumentError, "invalid destination image" unless destination.bytesize % 4 == 0 && dst_width.positive?
49
+ dst_height = destination.bytesize / 4 / dst_width
50
+ alpha = Float(opacity)
51
+ raise ArgumentError, "opacity must be between 0 and 1" unless alpha.between?(0.0, 1.0)
52
+
53
+ src_height.times do |sy|
54
+ dy = Integer(y) + sy
55
+ next unless dy.between?(0, dst_height - 1)
56
+ src_width.times do |sx|
57
+ dx = Integer(x) + sx
58
+ next unless dx.between?(0, dst_width - 1)
59
+ si = (sy * src_width + sx) * 4; di = (dy * dst_width + dx) * 4
60
+ sr, sg, sb, sa = src.byteslice(si, 4).bytes
61
+ sa = (sa * alpha).round
62
+ dr, dg, db, da = destination.byteslice(di, 4).bytes
63
+ source_alpha = sa / 255.0; destination_alpha = da / 255.0
64
+ out_alpha = source_alpha + destination_alpha * (1.0 - source_alpha)
65
+ if out_alpha.zero?
66
+ destination[di, 4] = "\0\0\0\0".b
67
+ else
68
+ destination[di, 4] = [
69
+ ((sr * source_alpha + dr * destination_alpha * (1.0 - source_alpha)) / out_alpha).round,
70
+ ((sg * source_alpha + dg * destination_alpha * (1.0 - source_alpha)) / out_alpha).round,
71
+ ((sb * source_alpha + db * destination_alpha * (1.0 - source_alpha)) / out_alpha).round,
72
+ (out_alpha * 255).round
73
+ ].pack("C4")
74
+ end
75
+ end
76
+ end
77
+ dst.replace(destination) if dst.respond_to?(:replace) && !dst.frozen?
78
+ destination
79
+ end
80
+
81
+ def diff_bounds(previous, current, width, height)
82
+ validate!(previous, width, height); validate!(current, width, height)
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
88
+ end
89
+ max_x.negative? ? nil : [min_x, min_y, max_x - min_x + 1, max_y - min_y + 1]
90
+ end
91
+
92
+ def bounds(value)
93
+ values = value.respond_to?(:to_a) ? value.to_a : value
94
+ raise ArgumentError, "bounds must contain x, y, width, height" unless values && values.length == 4
95
+ values.map { |item| Integer(item) }
96
+ end
97
+
98
+ def validate!(rgba, width, height)
99
+ raise ArgumentError, "invalid dimensions" unless Integer(width).positive? && Integer(height).positive?
100
+ raise ArgumentError, "invalid RGBA pixels" unless rgba.bytesize == width * height * 4
101
+ end
102
+ private_class_method :validate!
103
+ end
104
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wezen
4
+ Palette = Data.define(:colors, :transparent_index)
5
+ end
data/lib/wezen/png.rb ADDED
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wezen
4
+ module PNG
5
+ SIGNATURE = "\x89PNG\r\n\x1a\n".b.freeze
6
+ module_function
7
+
8
+ def encode(width, height, rgba, compression: 9)
9
+ validate(width, height, rgba)
10
+ scanlines = String.new(encoding: Encoding::BINARY)
11
+ height.times { |row| scanlines << "\0" << rgba.byteslice(row * width * 4, width * 4) }
12
+ SIGNATURE + chunk("IHDR", [width, height, 8, 6, 0, 0, 0].pack("NNC5")) +
13
+ chunk("IDAT", Zlib::Deflate.deflate(scanlines, Integer(compression))) + chunk("IEND", "".b)
14
+ end
15
+
16
+ def write(path, width, height, rgba, **options)
17
+ File.binwrite(path, encode(width, height, rgba, **options))
18
+ path
19
+ end
20
+
21
+ def chunk(kind, data)
22
+ [data.bytesize].pack("N") + kind + data + [Zlib.crc32(kind + data)].pack("N")
23
+ end
24
+
25
+ def validate(width, height, rgba)
26
+ raise ArgumentError, "invalid image dimensions" unless width.to_i.positive? && height.to_i.positive?
27
+ raise ArgumentError, "invalid RGBA pixels" unless rgba.bytesize == width * height * 4
28
+ end
29
+ private_class_method :validate
30
+ end
31
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wezen
4
+ module Quantize
5
+ module_function
6
+
7
+ def palette(animation, colors:, method: :median_cut)
8
+ raise ArgumentError, "unsupported quantizer: #{method}" unless %i[median_cut octree].include?(method)
9
+ limit = Integer(colors)
10
+ 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] } }
12
+ 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
+ opaque_limit = transparent ? limit - 1 : limit
15
+ colors_out = median_cut(pixels, opaque_limit)
16
+ if transparent
17
+ colors_out = [[0, 0, 0], *colors_out]
18
+ Palette.new(colors_out, 0)
19
+ else
20
+ Palette.new(colors_out, nil)
21
+ end
22
+ end
23
+
24
+ def apply(rgba, palette, width: nil, height: nil, dither: :floyd_steinberg)
25
+ raise ArgumentError, "unknown dither: #{dither}" unless %i[none bayer floyd_steinberg].include?(dither)
26
+ raise ArgumentError, "palette must be a Wezen::Palette" unless palette.respond_to?(:colors)
27
+ width ||= rgba.bytesize / 4
28
+ height ||= 1
29
+ apply_with_size(rgba, palette, width: width, height: height, dither: dither)
30
+ end
31
+
32
+ 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
34
+ colors = palette.colors
35
+ output = String.new(capacity: width * height, encoding: Encoding::BINARY)
36
+ errors = Array.new(width * 3, 0.0)
37
+ next_errors = Array.new(width * 3, 0.0)
38
+ rgba.bytes.each_slice(4).with_index do |pixel, index|
39
+ x = index % width; y = index / width
40
+ alpha = pixel[3]
41
+ if alpha < 128 && palette.transparent_index
42
+ output << palette.transparent_index
43
+ next
44
+ end
45
+ adjusted = pixel[0, 3].map.with_index do |channel, component|
46
+ value = channel.to_f
47
+ value += errors[x * 3 + component] if dither == :floyd_steinberg
48
+ if dither == :bayer
49
+ value += bayer(x, y) * 16 - 8
50
+ end
51
+ value.clamp(0, 255)
52
+ end
53
+ index_color = nearest(adjusted, colors, palette.transparent_index)
54
+ output << index_color
55
+ next unless dither == :floyd_steinberg
56
+
57
+ chosen = colors[index_color]
58
+ delta = 3.times.map { |channel| adjusted[channel] - chosen[channel] }
59
+ distribute(errors, next_errors, x, width, delta)
60
+ if x == width - 1
61
+ errors = next_errors
62
+ next_errors = Array.new(width * 3, 0.0)
63
+ end
64
+ end
65
+ output
66
+ end
67
+
68
+ def median_cut(pixels, limit)
69
+ boxes = [pixels.sort_by { |pixel| pixel[0] * 65_536 + pixel[1] * 256 + pixel[2] }]
70
+ while boxes.length < limit
71
+ candidate = boxes.each_with_index.max_by do |box, index|
72
+ ranges = 3.times.map { |axis| box.map { |pixel| pixel[axis] }.then { |values| values.max - values.min } }
73
+ [box.length, *ranges, -index]
74
+ end
75
+ break unless candidate
76
+ box, index = candidate
77
+ axis = 3.times.max_by { |item| [box.map { |pixel| pixel[item] }.then { |values| values.max - values.min }, -item] }
78
+ ordered = box.sort_by { |pixel| [pixel[axis], pixel[0], pixel[1], pixel[2]] }
79
+ cut = [ordered.length / 2, 1].max
80
+ break if cut >= ordered.length
81
+ boxes[index] = ordered[0, cut]
82
+ boxes << ordered[cut..]
83
+ end
84
+ boxes.map do |box|
85
+ box.transpose.map { |channel| (channel.sum.fdiv(channel.length)).round }
86
+ end.sort
87
+ end
88
+
89
+ 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
93
+ end
94
+
95
+ def bayer(x, y)
96
+ [[0, 8], [12, 4]][y % 2][x % 2] / 16.0
97
+ end
98
+
99
+ def distribute(errors, next_errors, x, width, delta)
100
+ add = ->(array, column, factor) { 3.times { |channel| array[column * 3 + channel] += delta[channel] * factor } if column.between?(0, width - 1) }
101
+ add.call(errors, x + 1, 7.0 / 16)
102
+ add.call(next_errors, x - 1, 3.0 / 16)
103
+ add.call(next_errors, x, 5.0 / 16)
104
+ add.call(next_errors, x + 1, 1.0 / 16)
105
+ end
106
+ private_class_method :median_cut, :nearest, :bayer, :distribute
107
+ end
108
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wezen
4
+ VERSION = "0.1.0"
5
+ end
data/lib/wezen.rb ADDED
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+ require_relative "wezen/version"
5
+
6
+ # Ruby 3.2 added Data. Keep the gem usable on the minimum supported Ruby too.
7
+ unless defined?(Data) && Data.respond_to?(:define)
8
+ Object.send(:remove_const, :Data) if defined?(Data)
9
+ Data = Struct
10
+ def Data.define(*members, &block)
11
+ Struct.new(*members) do
12
+ members.each { |member| undef_method :"#{member}=" }
13
+ define_method(:initialize) do |*values, **keywords|
14
+ values = members.map { |member| keywords.fetch(member) } if values.empty? && keywords.any?
15
+ raise ArgumentError, "wrong number of members" unless values.length == members.length
16
+
17
+ super(*values)
18
+ freeze
19
+ end
20
+ define_method(:with) { |**changes| self.class.new(**to_h.merge(changes)) }
21
+ class_eval(&block) if block
22
+ end
23
+ end
24
+ end
25
+
26
+ require_relative "wezen/animation"
27
+ require_relative "wezen/image"
28
+ require_relative "wezen/palette"
29
+ require_relative "wezen/quantize"
30
+ require_relative "wezen/png"
31
+ require_relative "wezen/apng"
32
+ require_relative "wezen/gif"
33
+ require_relative "wezen/cast"
34
+
35
+ module Wezen
36
+ class Error < StandardError; end
37
+ end
data/sig/wezen.rbs ADDED
@@ -0,0 +1,68 @@
1
+ module Wezen
2
+ VERSION: String
3
+ class Error < StandardError
4
+ end
5
+
6
+ class Frame
7
+ attr_reader rgba: String
8
+ attr_reader delay_ms: Integer
9
+ end
10
+
11
+ class Animation
12
+ attr_reader width: Integer
13
+ attr_reader height: Integer
14
+ attr_reader loop: Integer
15
+ def initialize: (width: Integer, height: Integer, ?loop: Integer) -> void
16
+ def add: (String rgba, delay_ms: Integer) -> self
17
+ def <<: (Frame frame) -> self
18
+ def size: () -> Integer
19
+ def duration_ms: () -> Integer
20
+ def each: () { (Frame) -> void } -> self
21
+ def coalesce: (?tolerance: Integer) -> Animation
22
+ def scale: (to_width: Integer, to_height: Integer) -> Animation
23
+ def crop: (untyped bounds) -> Animation
24
+ def drop_to: (fps: Numeric) -> Animation
25
+ end
26
+
27
+ class Palette
28
+ attr_reader colors: Array[Array[Integer]]
29
+ attr_reader transparent_index: Integer?
30
+ end
31
+
32
+ module Image
33
+ def self.scale: (String rgba, Integer width, Integer height, to_width: Integer, to_height: Integer) -> String
34
+ def self.crop: (String rgba, Integer width, Integer height, untyped bounds) -> String
35
+ def self.blend: (String dst, Integer dst_width, String src, Integer src_width, Integer src_height, x: Integer, y: Integer, ?opacity: Numeric) -> String
36
+ def self.diff_bounds: (String previous, String current, Integer width, Integer height) -> [Integer, Integer, Integer, Integer]?
37
+ end
38
+
39
+ module Quantize
40
+ def self.palette: (Animation animation, colors: Integer, ?method: Symbol) -> Palette
41
+ def self.apply: (String rgba, Palette palette, ?width: Integer?, ?height: Integer?, ?dither: Symbol) -> String
42
+ end
43
+
44
+ module PNG
45
+ def self.encode: (Integer width, Integer height, String rgba, ?compression: Integer) -> String
46
+ def self.write: (String path, Integer width, Integer height, String rgba, **untyped) -> String
47
+ end
48
+
49
+ module APNG
50
+ def self.encode: (Animation animation, ?compression: Integer, ?diff: bool) -> String
51
+ def self.write: (String path, Animation animation, **untyped) -> String
52
+ end
53
+
54
+ module GIF
55
+ def self.encode: (Animation animation, ?colors: Integer, ?palette: Symbol | Palette, ?dither: Symbol, ?diff: bool) -> String
56
+ def self.write: (String path, Animation animation, **untyped) -> String
57
+ end
58
+
59
+ module Cast
60
+ class Event
61
+ attr_reader time: Numeric
62
+ attr_reader kind: Symbol
63
+ attr_reader data: String
64
+ end
65
+ def self.encode: (width: Integer, height: Integer, events: Array[Event | Array[untyped]], ?title: String?, ?env: Hash[untyped, untyped]) -> String
66
+ def self.write: (String path, **untyped) -> String
67
+ end
68
+ end
metadata ADDED
@@ -0,0 +1,64 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: wezen
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Pure Ruby encoders and pixel primitives for deterministic demo recordings.
13
+ email:
14
+ - t.yudai92@gmail.com
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - CHANGELOG.md
20
+ - LICENSE.txt
21
+ - README.md
22
+ - docs/adr/000-template.md
23
+ - docs/adr/001-encode-only.md
24
+ - docs/adr/002-global-palette.md
25
+ - docs/adr/003-apng-default.md
26
+ - docs/adr/README.md
27
+ - lib/wezen.rb
28
+ - lib/wezen/animation.rb
29
+ - lib/wezen/apng.rb
30
+ - lib/wezen/cast.rb
31
+ - lib/wezen/gif.rb
32
+ - lib/wezen/image.rb
33
+ - lib/wezen/palette.rb
34
+ - lib/wezen/png.rb
35
+ - lib/wezen/quantize.rb
36
+ - lib/wezen/version.rb
37
+ - sig/wezen.rbs
38
+ homepage: https://github.com/noxdea/wezen
39
+ licenses:
40
+ - MIT
41
+ metadata:
42
+ allowed_push_host: https://rubygems.org
43
+ homepage_uri: https://github.com/noxdea/wezen
44
+ source_code_uri: https://github.com/noxdea/wezen/tree/main
45
+ changelog_uri: https://github.com/noxdea/wezen/blob/main/CHANGELOG.md
46
+ rubygems_mfa_required: 'true'
47
+ rdoc_options: []
48
+ require_paths:
49
+ - lib
50
+ required_ruby_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '3.1'
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ requirements: []
61
+ rubygems_version: 4.0.16
62
+ specification_version: 4
63
+ summary: Deterministic GIF, APNG, PNG, and asciinema encoders for Ruby
64
+ test_files: []