Skip to content

Commit 5eccfae

Browse files
matmenclaude
andcommitted
Encode lossy WebP: VP8 ported from libwebp, byte for byte like cwebp
Image.encodeWebP({ quality }) and Animation.encodeWebP({ quality }) encode lossy WebPs; without a quality they stay lossless. The VP8 encoder (src/codecs/vp8-enc*.ts) is ported from libwebp 1.6.0: RGB to YUV with gamma averaging and transparent area cleanup, analysis and k-means segments, mode decisions, quantization with DC error diffusion, trellis, adaptive token probabilities, filter strength and the bitstream. Its tables are read from libwebp's sources (scripts/vp8-enc-tables.py). Alpha is stored losslessly, filtered like libwebp does, compressed with our VP8L encoder. effort (0-6, default 4) is cwebp's -m. Every method writes the VP8 chunk cwebp writes: identical on 2520 combinations of 72 images (alpha, odd sizes, the test targets, PngSuite), methods 0-6 and qualities 0-100. tests/webp-lossy.js checks against cwebp hashes (scripts/webp-lossy-references.py). Faster than a straight port while deciding the same: methods 0-2 code the macroblocks of the statistics pass from recorded tokens instead of deciding them again, the intra 16x16 and chroma mode distortions come from row/column sums of the source, only the predictions used are made, 32-bit RGB reads. 1080p photo at q75: effort 0 ~155 ms, 2 ~235 ms, 4 ~1.2 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
1 parent 209cdfc commit 5eccfae

13 files changed

Lines changed: 3369 additions & 24 deletions

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@
2525
frames that use other frames (inter frames): image sequences only give their first frame, and of layered images
2626
whose shown layer is inter coded the highest intra coded layer is shown if it has the image's size (else they
2727
throw).
28+
- `Image.encodeWebP({ quality })` encodes lossy WebPs (VP8, with a losslessly compressed alpha plane if the image
29+
isn't opaque): ported from libwebp's encoder with its default settings, the VP8 bitstream is byte for byte the one
30+
cwebp writes for the same quality (0-100). `effort` (0-6, default 4) is cwebp's `-m`: 0-2 decide by distortion only
31+
and are several times faster, 5-6 search harder for smaller files. Without a quality WebPs stay lossless.
32+
`Animation.encodeWebP({ quality, effort })` encodes lossy frames the same way.
2833
- `DecodeOptions.toneMapping`: HDRs', PFMs' and HDR AVIFs' light can be brighter than (SDR) white. By default
2934
(`'reinhard'`) the brightest parts are compressed like Reinhard's extended tone mapping operator does, the brightest
3035
pixel becomes white (without the operator's automatic exposure, which turns night scenes grey); images no brighter

‎README.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,15 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too)
6161
, [dominantColor](https://imagescript.matmen.dev/classes/Image.html#dominantcolor), ...)
6262
- Encoding images as [PNGs](https://imagescript.matmen.dev/classes/Image.html#encode) (smallest lossless color type)
6363
, [JPEGs](https://imagescript.matmen.dev/classes/Image.html#encodejpeg)
64-
, lossless [WebPs](https://imagescript.matmen.dev/classes/Image.html#encodewebp)
64+
, lossless and lossy [WebPs](https://imagescript.matmen.dev/classes/Image.html#encodewebp) (lossy ones byte for
65+
byte like libwebp's cwebp)
6566
, [BMPs](https://imagescript.matmen.dev/classes/Image.html#encodebmp)
6667
, [QOIs](https://imagescript.matmen.dev/classes/Image.html#encodeqoi)
6768
, [Netpbm images](https://imagescript.matmen.dev/classes/Image.html#encodepnm) (PBM, PGM, PPM or PAM, whichever is
6869
smallest)
6970
and [TGAs](https://imagescript.matmen.dev/classes/Image.html#encodetga)
7071
- Encoding animations as [GIF](https://imagescript.matmen.dev/classes/Animation.html#encodegif),
71-
[APNG](https://imagescript.matmen.dev/classes/Animation.html#encodeapng) and lossless
72+
[APNG](https://imagescript.matmen.dev/classes/Animation.html#encodeapng) and lossless or lossy
7273
[WebP](https://imagescript.matmen.dev/classes/Animation.html#encodewebp), storing only what changes from frame to frame
7374

7475
---
@@ -93,8 +94,8 @@ const { Image } = require('imagescript');
9394
All coordinates are 0-based, `(0, 0)` is the top left pixel.
9495

9596
A bigger example is the [README image generation](https://github.com/matmen/ImageScript/blob/master/tests/readme.js).
96-
Upgrading from 1.x? Version 3 has breaking changes (0-based coordinates, removed lossy WebP encoding, Node.js 22.13+, ...),
97-
see the [changelog](https://github.com/matmen/ImageScript/blob/master/CHANGELOG.md).
97+
Upgrading from 1.x? Version 3 has breaking changes (0-based coordinates, lossy WebP encoding as `encodeWebP({ quality })`,
98+
Node.js 22.13+, ...), see the [changelog](https://github.com/matmen/ImageScript/blob/master/CHANGELOG.md).
9899

99100
[![Example](https://github.com/matmen/ImageScript/raw/master/tests/targets/readme.png)](https://github.com/matmen/ImageScript/blob/master/tests/readme.js)
100101

@@ -114,7 +115,8 @@ The deflate/inflate implementation is derived from [fflate](https://github.com/1
114115
The JPEG codec is based in part on the work of the Independent JPEG Group (libjpeg) and on
115116
[pdf.js](https://github.com/mozilla/pdf.js), the glyph rasterizer is translated from
116117
[font-rs](https://github.com/raphlinus/font-rs). The WebP codecs follow [RFC 9649](https://www.rfc-editor.org/rfc/rfc9649) and [RFC 6386](https://www.rfc-editor.org/rfc/rfc6386)
117-
and reproduce [libwebp](https://chromium.googlesource.com/webm/libwebp)'s color conversion, the TrueType hinting follows
118+
and reproduce [libwebp](https://chromium.googlesource.com/webm/libwebp)'s color conversion, the lossy WebP encoder is
119+
ported from libwebp's, the TrueType hinting follows
118120
[FreeType](https://freetype.org)'s interpreter (portions of this software are copyright © 2026 The FreeType Project),
119121
the text shaping is ported from [HarfBuzz](https://github.com/harfbuzz/harfbuzz) and uses data of the
120122
[Unicode Character Database](https://www.unicode.org/ucd/). The AV1 decoder is ported from

‎THIRD_PARTY_NOTICES.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ following projects, under their own licenses. Each derived file says so in its h
1212
| [font-rs](https://github.com/raphlinus/font-rs) by Raph Levien | `src/render/text.ts` (the `Rasterizer` class, also used for SVGs) | Apache-2.0 |
1313
| [RFC 9649](https://www.rfc-editor.org/rfc/rfc9649) (WebP Image Format) | `src/codecs/vp8l.ts` (lossless bitstream, short distance code table), `src/codecs/webp.ts` (container) | BSD-3-Clause (Revised BSD) |
1414
| [RFC 6386](https://www.rfc-editor.org/rfc/rfc6386) (VP8 Data Format and Decoding Guide, with the libvpx reference decoder) | `src/codecs/vp8-tables.ts` (probability and quantizer tables), `src/codecs/vp8.ts` (decoding procedures) | BSD-3-Clause |
15-
| [libwebp](https://chromium.googlesource.com/webm/libwebp) | `src/codecs/vp8.ts` (YUV to RGB conversion, "fancy" chroma upsampling), `src/codecs/webp.ts` (animation frame blending) | BSD-3-Clause |
15+
| [libwebp](https://chromium.googlesource.com/webm/libwebp) | `src/codecs/vp8.ts` (YUV to RGB conversion, "fancy" chroma upsampling), `src/codecs/webp.ts` (animation frame blending), `src/codecs/vp8-enc.ts`, `src/codecs/vp8-enc-dsp.ts` (the lossy encoder: RGB to YUV conversion, analysis, segments, rate-distortion optimized mode decisions, quantization, token probabilities, bitstream, alpha filtering), `src/codecs/vp8-enc-tables.ts` (its tables, generated from libwebp by `scripts/vp8-enc-tables.py`) | BSD-3-Clause |
1616
| [dav1d](https://code.videolan.org/videolan/dav1d) by VideoLAN, the dav1d authors and Two Orioles | `src/codecs/av1/*` (the AV1 decoder: bitstream parsing, symbol decoding, prediction, inverse transforms, loop filters, film grain; the tables in `src/codecs/av1/data.ts`, generated from dav1d by `scripts/av1-tables.c`) | BSD-2-Clause |
1717
| [libavif](https://github.com/AOMediaCodec/libavif) by Joe Drago | `src/codecs/avif.ts` (YUV to RGB conversion with bilinear chroma upsampling, alpha range expansion and unpremultiplication) | BSD-2-Clause |
1818
| [FreeType](https://freetype.org) | `src/render/hinting.ts` (behavior and parts of the logic of the TrueType interpreter) | FreeType License |
@@ -88,7 +88,8 @@ one decode function per scan type). The `Rasterizer` class in `src/render/text.t
8888

8989
The VP8 decoder follows the procedures of RFC 6386 and its reference decoder, its constant tables are extracted from
9090
the RFC. To decode exactly like libwebp, the YUV to RGB conversion, the chroma upsampling and the blending of animation
91-
frames reproduce libwebp's integer arithmetic.
91+
frames reproduce libwebp's integer arithmetic. The lossy encoder is ported from libwebp's (version 1.6.0, its default
92+
settings) and writes the same VP8 bitstreams as cwebp.
9293

9394
```
9495
RFC 6386 reference decoder: Copyright (c) 2010, 2011, Google Inc. All rights reserved.

‎scripts/vp8-enc-tables.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Writes src/codecs/vp8-enc-tables.ts: the tables of libwebp's lossy (VP8) encoder that aren't in the decoder's
2+
# tables, read from the C sources of a libwebp checkout (https://chromium.googlesource.com/webm/libwebp, BSD-3-Clause):
3+
# python scripts/vp8-enc-tables.py path/to/libwebp > src/codecs/vp8-enc-tables.ts
4+
import base64, re, struct, sys
5+
6+
root = sys.argv[1].rstrip('/') + '/'
7+
8+
9+
def table(path, name):
10+
"""the values of a C array, flattened"""
11+
s = open(root + path).read()
12+
m = re.search(r'\b' + re.escape(name) + r'\s*(\[[^=]*\])\s*=\s*\{', s)
13+
i = j = m.end()
14+
depth = 1
15+
while depth:
16+
depth += {'{': 1, '}': -1}.get(s[j], 0)
17+
j += 1
18+
body = re.sub(r'//[^\n]*', '', re.sub(r'/\*.*?\*/', '', s[i:j - 1], flags=re.S))
19+
return [int(x, 0) for x in re.findall(r'-?(?:0x[0-9a-fA-F]+|\d+)', body)]
20+
21+
22+
def ints(name, comment, values):
23+
rows = [', '.join(str(v) for v in values[i:i + 32]) for i in range(0, len(values), 32)]
24+
print(f'/** {comment} */\nexport const {name} = [\n ' + ',\n '.join(rows) + '\n];\n')
25+
26+
27+
def words(name, comment, values):
28+
data = base64.b64encode(struct.pack(f'<{len(values)}H', *values)).decode()
29+
print(f'/** {comment} (16 bit, base64) */\nexport const {name} = \'{data}\';\n')
30+
31+
32+
print('''/*!
33+
* Generated by scripts/vp8-enc-tables.py from libwebp, don't edit. Copyright (c) 2010 Google Inc., BSD-3-Clause License
34+
* (see THIRD_PARTY_NOTICES.md)
35+
*/
36+
''')
37+
ints('ENTROPY_COST', 'VP8EntropyCost: the cost of a bit by probability (1/256 bits)', table('src/dsp/cost.c', 'VP8EntropyCost'))
38+
words('LEVEL_FIXED_COSTS', 'VP8LevelFixedCosts: the part of the cost of a level that doesn\'t depend on probabilities',
39+
table('src/dsp/cost.c', 'VP8LevelFixedCosts'))
40+
ints('LEVEL_CODES', 'VP8LevelCodes[67][2]: the contexts used to code a level (pattern) and their bits',
41+
table('src/enc/cost_enc.c', 'VP8LevelCodes'))
42+
words('FIXED_COSTS_I4', 'VP8FixedCostsI4[top][left][mode]: the costs of the intra 4x4 modes',
43+
table('src/enc/cost_enc.c', 'VP8FixedCostsI4'))
44+
ints('BMODES_PROBA', 'kBModesProba[top][left][9]: the probabilities of the intra 4x4 modes (encoder\'s mode order)',
45+
table('src/enc/tree_enc.c', 'kBModesProba'))
46+
ints('AC_TABLE2', 'kAcTable2: the AC quantizers of the luma DC (Y2) blocks', table('src/enc/quant_enc.c', 'kAcTable2'))
47+
ints('LEVELS_FROM_DELTA', 'kLevelsFromDelta[sharpness][delta]: the filter strength that smooths an edge step',
48+
table('src/enc/filter_enc.c', 'kLevelsFromDelta'))

‎scripts/webp-lossy-references.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Prints the sha256 of the VP8 chunks cwebp (libwebp) writes for some of tests/targets, with its default settings and
2+
# a quality (and some with other methods, ImageScript's efforts), for tests/webp-lossy.js (ImageScript's lossy encoder writes the same). The images are passed as PPM/PAM,
3+
# which cwebp reads without libpng:
4+
# CWEBP=path/to/cwebp python scripts/webp-lossy-references.py
5+
import hashlib, os, struct, subprocess, tempfile
6+
from pathlib import Path
7+
from PIL import Image
8+
9+
root = Path(__file__).resolve().parent.parent
10+
CWEBP = os.environ.get('CWEBP', 'cwebp')
11+
tmp = Path(tempfile.mkdtemp())
12+
images = ['external.png', 'potato.png', 'issues.png', 'resize.png', 'font-4.png']
13+
qualities = [10, 75, 95]
14+
# the other methods (at quality 75)
15+
method_images = ['external.png', 'potato.png', 'font-4.png']
16+
methods = [0, 1, 2, 3, 5, 6]
17+
18+
19+
def vp8_chunk(data):
20+
o = 12
21+
while o + 8 <= len(data):
22+
size = struct.unpack('<I', data[o + 4:o + 8])[0]
23+
if data[o:o + 4] == b'VP8 ':
24+
return data[o + 8:o + 8 + size]
25+
o += 8 + size + (size & 1)
26+
27+
28+
for name in images:
29+
im = Image.open(root / 'tests' / 'targets' / name)
30+
alpha = im.mode in ('RGBA', 'LA') or 'transparency' in im.info
31+
im = im.convert('RGBA' if alpha else 'RGB')
32+
w, h = im.size
33+
src = tmp / ('in.pam' if alpha else 'in.ppm')
34+
header = f'P7\nWIDTH {w}\nHEIGHT {h}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n' if alpha else f'P6\n{w} {h}\n255\n'
35+
src.write_bytes(header.encode() + im.tobytes())
36+
runs = [(q, 4) for q in qualities] + [(75, m) for m in methods if name in method_images]
37+
for q, m in runs:
38+
subprocess.run([CWEBP, '-quiet', '-q', str(q), '-m', str(m), str(src), '-o', str(tmp / 'out.webp')], check=True)
39+
key = f'{name} {q}' if m == 4 else f'{name} {q} {m}'
40+
print(f" '{key}': '{hashlib.sha256(vp8_chunk((tmp / 'out.webp').read_bytes())).hexdigest()}',")

‎src/animation.ts‎

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export interface AnimationDecodeOptions {
4242
maxPixels?: number;
4343
}
4444

45-
/** Options of {@link Animation.encodeWebP}, and those all animation encoders have */
45+
/** The options all animation encoders have */
4646
export interface AnimationEncodeOptions {
4747
/** Metadata to include, see {@link Metadata} */
4848
metadata?: Metadata;
@@ -54,6 +54,17 @@ export interface AnimationEncodeOptions {
5454
optimize?: boolean;
5555
}
5656

57+
/** Options of {@link Animation.encodeWebP} */
58+
export interface WebPAnimationEncodeOptions extends AnimationEncodeOptions {
59+
/**
60+
* Lossy frames (0-100, like libwebp's quality: 75 is its default), much smaller than lossless ones. Without it the
61+
* frames are lossless
62+
*/
63+
quality?: number;
64+
/** How much effort lossy frames take (0-6), see {@link WebPEncodeOptions.effort}. Default: `4` */
65+
effort?: number;
66+
}
67+
5768
/** Options of {@link Animation.encodeGIF} */
5869
export interface GIFEncodeOptions extends AnimationEncodeOptions {
5970
/** 1-100, scales the palette size from 8 colors (1) to 256 colors (90 and above). Default: `95` */
@@ -270,17 +281,22 @@ export class Animation extends Array<Frame> {
270281
}
271282

272283
/**
273-
* Encodes the animation as lossless animated WebP. Each frame is stored as the area that changed since the frame
274-
* before, identical frames are merged (unless the `optimize` option is false): the file shows exactly the same
275-
* animation, but doesn't necessarily decode to the same frames
276-
* @param options See {@link AnimationEncodeOptions}
284+
* Encodes the animation as animated WebP, lossless or (with a quality) lossy. Each frame is stored as the area that
285+
* changed since the frame before, identical frames are merged (unless the `optimize` option is false): the file
286+
* shows the same animation, but doesn't necessarily decode to the same frames
287+
* @param options See {@link WebPAnimationEncodeOptions}
277288
*/
278-
async encodeWebP({ metadata = {}, optimize = true }: AnimationEncodeOptions = {}): Promise<Uint8Array> {
289+
async encodeWebP({ metadata = {}, optimize = true, quality, effort = 4 }: WebPAnimationEncodeOptions = {}): Promise<Uint8Array> {
279290
this.__validate__();
280291
if (!this.length) throw new Error('An animation needs at least one frame');
292+
if (quality !== undefined) {
293+
const q = finite(quality, 'quality');
294+
if (q < 0 || q > 100) throw new RangeError(`quality must be 0-100, not ${q}`);
295+
if (!Number.isInteger(effort) || effort < 0 || effort > 6) throw new RangeError(`effort must be an integer 0-6, not ${effort}`);
296+
}
281297

282298
const frames = optimize ? this.__optimized__({ replace: true, even: true, alpha: 'frame' }) : this.__webpFrames__();
283-
return webp.encodeAnimation(frames, this.width, this.height, toPlays(this.loopCount), exifData(resolveMetadata(metadata)));
299+
return webp.encodeAnimation(frames, this.width, this.height, toPlays(this.loopCount), exifData(resolveMetadata(metadata)), quality, effort);
284300
}
285301

286302
/**

0 commit comments

Comments
 (0)