pura-webp 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.
@@ -0,0 +1,434 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pura
4
+ module Webp
5
+ class Encoder
6
+ def self.encode(image, path, **_options)
7
+ encoder = new(image)
8
+ data = encoder.encode
9
+ File.binwrite(path, data)
10
+ data.bytesize
11
+ end
12
+
13
+ def initialize(image)
14
+ @image = image
15
+ @width = image.width
16
+ @height = image.height
17
+ end
18
+
19
+ def encode
20
+ vp8l_data = encode_vp8l
21
+ wrap_riff(vp8l_data)
22
+ end
23
+
24
+ private
25
+
26
+ def wrap_riff(vp8l_payload)
27
+ chunk = String.new(encoding: Encoding::BINARY)
28
+ chunk << "VP8L"
29
+ chunk << [vp8l_payload.bytesize].pack("V")
30
+ chunk << vp8l_payload
31
+ chunk << "\x00" if vp8l_payload.bytesize.odd?
32
+
33
+ riff = String.new(encoding: Encoding::BINARY)
34
+ riff << "RIFF"
35
+ riff << [4 + chunk.bytesize].pack("V")
36
+ riff << "WEBP"
37
+ riff << chunk
38
+ riff
39
+ end
40
+
41
+ def encode_vp8l
42
+ bw = BitWriter.new
43
+
44
+ # VP8L signature
45
+ bw.write_bits(0x2F, 8)
46
+
47
+ # Image descriptor: width-1 (14 bits), height-1 (14 bits), alpha (1 bit), version (3 bits)
48
+ bw.write_bits(@width - 1, 14)
49
+ bw.write_bits(@height - 1, 14)
50
+ bw.write_bits(0, 1) # no alpha
51
+ bw.write_bits(0, 3) # version 0
52
+
53
+ # No transforms
54
+ bw.write_bits(0, 1)
55
+
56
+ # --- Image Data (Section 5) ---
57
+
58
+ # Color cache: not used
59
+ bw.write_bits(0, 1)
60
+
61
+ # Meta huffman: not used (main image is "recursive" so this bit is needed)
62
+ bw.write_bits(0, 1)
63
+
64
+ # Collect pixels
65
+ pixels = @image.pixels
66
+ num_pixels = @width * @height
67
+
68
+ greens = Array.new(num_pixels)
69
+ reds = Array.new(num_pixels)
70
+ blues = Array.new(num_pixels)
71
+
72
+ num_pixels.times do |i|
73
+ offset = i * 3
74
+ reds[i] = pixels.getbyte(offset)
75
+ greens[i] = pixels.getbyte(offset + 1)
76
+ blues[i] = pixels.getbyte(offset + 2)
77
+ end
78
+
79
+ # Build histograms
80
+ green_hist = Array.new(280, 0)
81
+ red_hist = Array.new(256, 0)
82
+ blue_hist = Array.new(256, 0)
83
+ greens.each { |v| green_hist[v] += 1 }
84
+ reds.each { |v| red_hist[v] += 1 }
85
+ blues.each { |v| blue_hist[v] += 1 }
86
+
87
+ # Build huffman code lengths
88
+ green_lengths = build_huffman_lengths(green_hist, 280)
89
+ red_lengths = build_huffman_lengths(red_hist, 256)
90
+ blue_lengths = build_huffman_lengths(blue_hist, 256)
91
+ alpha_lengths = Array.new(256, 0)
92
+ alpha_lengths[255] = 1
93
+ dist_lengths = Array.new(40, 0)
94
+
95
+ # Write 5 huffman tables (all simple)
96
+ write_code_lengths(bw, green_lengths)
97
+ write_code_lengths(bw, red_lengths)
98
+ write_code_lengths(bw, blue_lengths)
99
+ write_code_lengths(bw, alpha_lengths)
100
+ write_code_lengths(bw, dist_lengths)
101
+
102
+ # Build actual codes
103
+ green_codes = canonical_codes(green_lengths)
104
+ red_codes = canonical_codes(red_lengths)
105
+ blue_codes = canonical_codes(blue_lengths)
106
+ alpha_codes = canonical_codes(alpha_lengths)
107
+
108
+ # Encode pixels
109
+ num_pixels.times do |i|
110
+ emit_code(bw, green_codes, greens[i])
111
+ emit_code(bw, red_codes, reds[i])
112
+ emit_code(bw, blue_codes, blues[i])
113
+ emit_code(bw, alpha_codes, 255)
114
+ end
115
+
116
+ bw.finish
117
+ end
118
+
119
+ # Quantize channel to 2 most frequent values
120
+ def quantize_channel(values, uniq)
121
+ # Find 2 most frequent
122
+ freq = Hash.new(0)
123
+ values.each { |v| freq[v] += 1 }
124
+ top2 = freq.sort_by { |_, c| -c }.first(2).map(&:first).sort
125
+
126
+ values.map do |v|
127
+ # Map to nearest of top2
128
+ if (v - top2[0]).abs <= (v - top2[1]).abs
129
+ top2[0]
130
+ else
131
+ top2[1]
132
+ end
133
+ end
134
+ end
135
+
136
+ # Build simple code lengths (1 or 2 symbols only)
137
+ def simple_lengths(uniq_values, max_symbols)
138
+ lengths = Array.new(max_symbols, 0)
139
+ if uniq_values.size == 1
140
+ lengths[uniq_values[0]] = 1
141
+ elsif uniq_values.size == 2
142
+ lengths[uniq_values[0]] = 1
143
+ lengths[uniq_values[1]] = 1
144
+ end
145
+ lengths
146
+ end
147
+
148
+ # Build huffman code lengths from histogram
149
+ def build_huffman_lengths(hist, max_symbols)
150
+ non_zero = []
151
+ hist.each_with_index { |c, s| non_zero << [c, s] if c > 0 }
152
+
153
+ lengths = Array.new(max_symbols, 0)
154
+
155
+ if non_zero.empty?
156
+ return lengths
157
+ elsif non_zero.size == 1
158
+ lengths[non_zero[0][1]] = 1
159
+ return lengths
160
+ end
161
+
162
+ # Build huffman tree
163
+ nodes = non_zero.sort_by { |c, _| c }.map { |c, s| { count: c, sym: s } }
164
+
165
+ while nodes.size > 1
166
+ a = nodes.shift
167
+ b = nodes.shift
168
+ parent = { count: a[:count] + b[:count], sym: nil, left: a, right: b }
169
+ idx = nodes.bsearch_index { |n| n[:count] >= parent[:count] } || nodes.size
170
+ nodes.insert(idx, parent)
171
+ end
172
+
173
+ # Extract lengths
174
+ assign_depth(nodes[0], 0, lengths)
175
+
176
+ # Enforce max length (15 for data, 7 for CL codes)
177
+ enforce_max_length(lengths, 15)
178
+ lengths
179
+ end
180
+
181
+ def assign_depth(node, depth, lengths)
182
+ if node[:left].nil?
183
+ lengths[node[:sym]] = [depth, 1].max
184
+ else
185
+ assign_depth(node[:left], depth + 1, lengths)
186
+ assign_depth(node[:right], depth + 1, lengths)
187
+ end
188
+ end
189
+
190
+ # Enforce max code length while maintaining valid prefix code
191
+ def enforce_max_length(lengths, max_len)
192
+ return if lengths.max.to_i <= max_len
193
+
194
+ # Collect non-zero lengths with symbols
195
+ syms = []
196
+ lengths.each_with_index { |l, s| syms << [l, s] if l > 0 }
197
+ return if syms.empty?
198
+
199
+ # Cap all lengths
200
+ syms.each { |pair| pair[0] = max_len if pair[0] > max_len }
201
+
202
+ # Verify Kraft inequality: sum of 2^(-l) <= 1
203
+ # Multiply by 2^max_len: sum of 2^(max_len - l) <= 2^max_len
204
+ kraft_limit = 1 << max_len
205
+ loop do
206
+ kraft_sum = syms.sum { |l, _| 1 << (max_len - l) }
207
+ break if kraft_sum <= kraft_limit
208
+
209
+ # Find the longest code and shorten it
210
+ syms.sort_by! { |l, _| -l }
211
+ # Take from longest, give to shorter
212
+ syms[0][0] -= 1 if syms[0][0] > 1
213
+ end
214
+
215
+ # Write back
216
+ lengths.fill(0)
217
+ syms.each { |l, s| lengths[s] = l }
218
+ end
219
+
220
+ # Build canonical huffman codes from lengths
221
+ def canonical_codes(lengths)
222
+ max_len = lengths.max || 0
223
+ return {} if max_len == 0
224
+
225
+ bl_count = Array.new(max_len + 1, 0)
226
+ lengths.each { |l| bl_count[l] += 1 if l > 0 }
227
+
228
+ next_code = Array.new(max_len + 1, 0)
229
+ code = 0
230
+ 1.upto(max_len) do |bits|
231
+ code = (code + bl_count[bits - 1]) << 1
232
+ next_code[bits] = code
233
+ end
234
+
235
+ codes = {}
236
+ lengths.each_with_index do |len, sym|
237
+ next if len == 0
238
+
239
+ codes[sym] = [next_code[len], len]
240
+ next_code[len] += 1
241
+ end
242
+ codes
243
+ end
244
+
245
+ def emit_code(bw, codes, sym)
246
+ if codes.size <= 1
247
+ # Single-symbol simple code: 0 bits needed
248
+ return
249
+ end
250
+
251
+ entry = codes[sym]
252
+ return unless entry
253
+
254
+ code, len = entry
255
+ return if len == 0 # singleton in normal huffman
256
+
257
+ # VP8L: huffman codes written MSB first
258
+ len.times do |i|
259
+ bw.write_bits((code >> (len - 1 - i)) & 1, 1)
260
+ end
261
+ end
262
+
263
+ # Write code lengths to bitstream using the VP8L format
264
+ def write_code_lengths(bw, lengths)
265
+ # Find how many symbols we actually have
266
+ non_zero_count = lengths.count { |l| l > 0 }
267
+ non_zero_syms = []
268
+ lengths.each_with_index { |l, s| non_zero_syms << s if l > 0 }
269
+
270
+ if non_zero_count == 0
271
+ # Write simple code with 1 symbol (symbol 0)
272
+ bw.write_bits(1, 1) # is_simple
273
+ bw.write_bits(0, 1) # num_symbols - 1 = 0
274
+ bw.write_bits(0, 1) # is_first_8bit = false (1-bit symbol)
275
+ bw.write_bits(0, 1) # symbol = 0
276
+ elsif non_zero_count == 1
277
+ sym = non_zero_syms[0]
278
+ bw.write_bits(1, 1) # is_simple
279
+ bw.write_bits(0, 1) # num_symbols - 1 = 0
280
+ if sym < 2
281
+ bw.write_bits(0, 1) # 1-bit symbol
282
+ bw.write_bits(sym, 1)
283
+ else
284
+ bw.write_bits(1, 1) # 8-bit symbol
285
+ bw.write_bits(sym, 8)
286
+ end
287
+ elsif non_zero_count == 2
288
+ bw.write_bits(1, 1) # is_simple
289
+ bw.write_bits(1, 1) # num_symbols - 1 = 1 (2 symbols)
290
+ s0 = non_zero_syms[0]
291
+ s1 = non_zero_syms[1]
292
+ if s0 < 2
293
+ bw.write_bits(0, 1) # 1-bit
294
+ bw.write_bits(s0, 1)
295
+ else
296
+ bw.write_bits(1, 1) # 8-bit
297
+ bw.write_bits(s0, 8)
298
+ end
299
+ bw.write_bits(s1, 8)
300
+ else
301
+ write_normal_code_lengths(bw, lengths)
302
+ end
303
+ end
304
+
305
+ def write_normal_code_lengths(bw, lengths)
306
+ bw.write_bits(0, 1) # is_simple = false
307
+
308
+ # Must cover all symbols in the alphabet (lengths.size)
309
+ # The decoder reads alphabet_size code lengths
310
+ num_symbols = lengths.size
311
+
312
+ # Code length alphabet: 0-15 literal lengths, 16=repeat, 17=zero run 3-10, 18=zero run 11-138
313
+ # VP8L code length code order
314
+ kCodeLengthCodeOrder = [17, 18, 0, 1, 2, 3, 4, 5, 16, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
315
+
316
+ # RLE encode the code lengths
317
+ rle = rle_encode(lengths, num_symbols)
318
+
319
+ # Build histogram of RLE symbols
320
+ cl_hist = Array.new(19, 0)
321
+ rle.each { |sym, _, _| cl_hist[sym] += 1 }
322
+
323
+ # Build code lengths for code length alphabet
324
+ cl_lengths = build_huffman_lengths(cl_hist, 19)
325
+ # Code length codes max length is 7 (stored in 3 bits)
326
+ enforce_max_length(cl_lengths, 7)
327
+
328
+ # Determine num_code_length_codes (at least 4)
329
+ num_cl = 4
330
+ kCodeLengthCodeOrder.each_with_index do |order_idx, i|
331
+ num_cl = i + 1 if cl_lengths[order_idx] > 0
332
+ end
333
+ num_cl = [num_cl, 4].max
334
+
335
+ bw.write_bits(num_cl - 4, 4)
336
+
337
+ # Write code length code lengths
338
+ num_cl.times do |i|
339
+ bw.write_bits(cl_lengths[kCodeLengthCodeOrder[i]], 3)
340
+ end
341
+
342
+ # Build codes for code length symbols
343
+ cl_codes = canonical_codes(cl_lengths)
344
+
345
+ # max_symbol flag: 0 = use default max_symbol
346
+ bw.write_bits(0, 1)
347
+
348
+ # Write the RLE-encoded code lengths
349
+ rle.each do |sym, extra_bits, extra_val|
350
+ emit_code(bw, cl_codes, sym)
351
+ case sym
352
+ when 16 then bw.write_bits(extra_val, 2)
353
+ when 17 then bw.write_bits(extra_val, 3)
354
+ when 18 then bw.write_bits(extra_val, 7)
355
+ end
356
+ end
357
+ end
358
+
359
+ def rle_encode(lengths, num_symbols)
360
+ result = []
361
+ i = 0
362
+ while i < num_symbols
363
+ val = lengths[i]
364
+ if val == 0
365
+ run = 0
366
+ run += 1 while i + run < num_symbols && lengths[i + run] == 0
367
+ i += run
368
+ while run > 0
369
+ if run >= 11
370
+ extra = [run - 11, 127].min
371
+ result << [18, 7, extra]
372
+ run -= 11 + extra
373
+ elsif run >= 3
374
+ extra = run - 3
375
+ result << [17, 3, extra]
376
+ run -= 3 + extra
377
+ else
378
+ result << [0, 0, 0]
379
+ run -= 1
380
+ end
381
+ end
382
+ else
383
+ result << [val, 0, 0]
384
+ i += 1
385
+ # Count repeats of same value
386
+ total_run = 0
387
+ total_run += 1 while i + total_run < num_symbols && lengths[i + total_run] == val
388
+ remaining = total_run
389
+ while remaining >= 3
390
+ extra = [remaining - 3, 3].min
391
+ result << [16, 2, extra]
392
+ remaining -= 3 + extra
393
+ end
394
+ remaining.times do
395
+ result << [val, 0, 0]
396
+ end
397
+ i += total_run
398
+ end
399
+ end
400
+ result
401
+ end
402
+
403
+ # Bit writer (LSB-first, VP8L format)
404
+ class BitWriter
405
+ def initialize
406
+ @data = String.new(encoding: Encoding::BINARY)
407
+ @current = 0
408
+ @bits = 0
409
+ end
410
+
411
+ def write_bits(value, num_bits)
412
+ num_bits.times do |i|
413
+ @current |= ((value >> i) & 1) << @bits
414
+ @bits += 1
415
+ flush_byte if @bits == 8
416
+ end
417
+ end
418
+
419
+ def finish
420
+ @data << (@current & 0xFF).chr if @bits > 0
421
+ @data
422
+ end
423
+
424
+ private
425
+
426
+ def flush_byte
427
+ @data << (@current & 0xFF).chr
428
+ @current = 0
429
+ @bits = 0
430
+ end
431
+ end
432
+ end
433
+ end
434
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pura
4
+ module Webp
5
+ class Image
6
+ attr_reader :width, :height, :pixels
7
+
8
+ def initialize(width, height, pixels)
9
+ @width = width
10
+ @height = height
11
+ @pixels = pixels.b
12
+ expected = width * height * 3
13
+ return if @pixels.bytesize == expected
14
+
15
+ raise ArgumentError, "pixel data size #{@pixels.bytesize} != expected #{expected} (#{width}x#{height}x3)"
16
+ end
17
+
18
+ def to_rgb_array
19
+ result = Array.new(width * height)
20
+ i = 0
21
+ offset = 0
22
+ while offset < @pixels.bytesize
23
+ result[i] = [@pixels.getbyte(offset), @pixels.getbyte(offset + 1), @pixels.getbyte(offset + 2)]
24
+ i += 1
25
+ offset += 3
26
+ end
27
+ result
28
+ end
29
+
30
+ def pixel_at(x, y)
31
+ raise IndexError, "coordinates out of bounds" if x.negative? || x >= @width || y.negative? || y >= @height
32
+
33
+ offset = ((y * @width) + x) * 3
34
+ [@pixels.getbyte(offset), @pixels.getbyte(offset + 1), @pixels.getbyte(offset + 2)]
35
+ end
36
+
37
+ def to_ppm
38
+ header = "P6\n#{@width} #{@height}\n255\n"
39
+ header.b + @pixels
40
+ end
41
+
42
+ def resize(new_width, new_height, interpolation: :bilinear)
43
+ raise ArgumentError, "width must be positive" unless new_width.positive?
44
+ raise ArgumentError, "height must be positive" unless new_height.positive?
45
+
46
+ if interpolation == :nearest
47
+ resize_nearest(new_width, new_height)
48
+ else
49
+ resize_bilinear(new_width, new_height)
50
+ end
51
+ end
52
+
53
+ def resize_fit(max_width, max_height, interpolation: :bilinear)
54
+ raise ArgumentError, "max_width must be positive" unless max_width.positive?
55
+ raise ArgumentError, "max_height must be positive" unless max_height.positive?
56
+
57
+ scale = [max_width.to_f / @width, max_height.to_f / @height].min
58
+ scale = [scale, 1.0].min
59
+ new_width = (@width * scale).round
60
+ new_height = (@height * scale).round
61
+ new_width = 1 if new_width < 1
62
+ new_height = 1 if new_height < 1
63
+ resize(new_width, new_height, interpolation: interpolation)
64
+ end
65
+
66
+ def resize_fill(fill_width, fill_height, interpolation: :bilinear)
67
+ raise ArgumentError, "width must be positive" unless fill_width.positive?
68
+ raise ArgumentError, "height must be positive" unless fill_height.positive?
69
+
70
+ scale = [fill_width.to_f / @width, fill_height.to_f / @height].max
71
+ scaled_w = (@width * scale).round
72
+ scaled_h = (@height * scale).round
73
+ scaled_w = 1 if scaled_w < 1
74
+ scaled_h = 1 if scaled_h < 1
75
+
76
+ scaled = resize(scaled_w, scaled_h, interpolation: interpolation)
77
+
78
+ crop_x = (scaled_w - fill_width) / 2
79
+ crop_y = (scaled_h - fill_height) / 2
80
+ scaled.crop(crop_x, crop_y, fill_width, fill_height)
81
+ end
82
+
83
+ def crop(x, y, w, h)
84
+ out = String.new(encoding: Encoding::BINARY, capacity: w * h * 3)
85
+ h.times do |row|
86
+ src_offset = (((y + row) * @width) + x) * 3
87
+ out << @pixels.byteslice(src_offset, w * 3)
88
+ end
89
+ Image.new(w, h, out)
90
+ end
91
+
92
+ private
93
+
94
+ def resize_nearest(new_width, new_height)
95
+ out = String.new(encoding: Encoding::BINARY, capacity: new_width * new_height * 3)
96
+ x_ratio = @width.to_f / new_width
97
+ y_ratio = @height.to_f / new_height
98
+
99
+ new_height.times do |y|
100
+ src_y = (y * y_ratio).to_i
101
+ src_y = @height - 1 if src_y >= @height
102
+ new_width.times do |x|
103
+ src_x = (x * x_ratio).to_i
104
+ src_x = @width - 1 if src_x >= @width
105
+ offset = ((src_y * @width) + src_x) * 3
106
+ out << @pixels.byteslice(offset, 3)
107
+ end
108
+ end
109
+
110
+ Image.new(new_width, new_height, out)
111
+ end
112
+
113
+ def resize_bilinear(new_width, new_height)
114
+ out = String.new(encoding: Encoding::BINARY, capacity: new_width * new_height * 3)
115
+ x_ratio = (@width - 1).to_f / [new_width - 1, 1].max
116
+ y_ratio = (@height - 1).to_f / [new_height - 1, 1].max
117
+
118
+ new_height.times do |y|
119
+ src_y = y * y_ratio
120
+ y0 = src_y.to_i
121
+ y1 = [y0 + 1, @height - 1].min
122
+ y_frac = src_y - y0
123
+
124
+ new_width.times do |x|
125
+ src_x = x * x_ratio
126
+ x0 = src_x.to_i
127
+ x1 = [x0 + 1, @width - 1].min
128
+ x_frac = src_x - x0
129
+
130
+ off00 = ((y0 * @width) + x0) * 3
131
+ off10 = ((y0 * @width) + x1) * 3
132
+ off01 = ((y1 * @width) + x0) * 3
133
+ off11 = ((y1 * @width) + x1) * 3
134
+
135
+ 3.times do |c|
136
+ v00 = @pixels.getbyte(off00 + c)
137
+ v10 = @pixels.getbyte(off10 + c)
138
+ v01 = @pixels.getbyte(off01 + c)
139
+ v11 = @pixels.getbyte(off11 + c)
140
+
141
+ val = (v00 * (1 - x_frac) * (1 - y_frac)) +
142
+ (v10 * x_frac * (1 - y_frac)) +
143
+ (v01 * (1 - x_frac) * y_frac) +
144
+ (v11 * x_frac * y_frac)
145
+
146
+ val = val.round
147
+ val = 0 if val.negative?
148
+ val = 255 if val > 255
149
+ out << val.chr
150
+ end
151
+ end
152
+ end
153
+
154
+ Image.new(new_width, new_height, out)
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pura
4
+ module Webp
5
+ VERSION = "0.1.0"
6
+ end
7
+ end