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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +52 -0
- data/docs/adr/000-template.md +24 -0
- data/docs/adr/001-encode-only.md +26 -0
- data/docs/adr/002-global-palette.md +27 -0
- data/docs/adr/003-apng-default.md +26 -0
- data/docs/adr/README.md +10 -0
- data/lib/wezen/animation.rb +102 -0
- data/lib/wezen/apng.rb +71 -0
- data/lib/wezen/cast.rb +36 -0
- data/lib/wezen/gif.rb +148 -0
- data/lib/wezen/image.rb +104 -0
- data/lib/wezen/palette.rb +5 -0
- data/lib/wezen/png.rb +31 -0
- data/lib/wezen/quantize.rb +108 -0
- data/lib/wezen/version.rb +5 -0
- data/lib/wezen.rb +37 -0
- data/sig/wezen.rbs +68 -0
- metadata +64 -0
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
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.
|
data/docs/adr/README.md
ADDED
|
@@ -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
|
data/lib/wezen/image.rb
ADDED
|
@@ -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
|
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
|
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: []
|