Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,20 @@

### New

- **WebP decoding**: lossy, lossless, with alpha and animated (#19, #20). Still images decode with `Image.decode()`,
animated ones with `Animation.decode()`. The output is bit-exact with libwebp (checked against libwebp's test
vectors and their reference checksums).
- **WebP encoding** (lossless): `Image.encodeWebP()`, typically 20-30% smaller than PNG.
- **APNG** decoding and encoding (#6): `Animation.decode()` reads animated PNGs (broken animations fall back to the
default image, like browsers do), `Animation.encodeAPNG()` writes them with full alpha. `Image.decode()` still
returns an APNG's default image.
- **`Animation`**, the base class of `GIF`: an array of frames that decodes GIF and APNG (`Animation.decode()`) and
encodes `encodeGIF()` and `encodeAPNG()`. `GIF` keeps its API.
- **`Animation`**, the base class of `GIF`: an array of frames that decodes GIF, APNG and WebP
(`Animation.decode()`) and encodes `encodeGIF()`, `encodeAPNG()` and `encodeWebP()`. `GIF` keeps its API.
- `decode()` returns animated WebPs as `Animation`; `ImageType` recognizes WebP (`isWebP()`, `getType() === 'webp'`).

### Deprecated

- **`GIF`**: use `Animation`, which works the same and also handles APNG (`new GIF(frames).encode()` becomes
- **`GIF`**: use `Animation`, which works the same and also handles APNG and WebP (`new GIF(frames).encode()` becomes
`new Animation(frames).encodeGIF()`, `GIF.decode()` becomes `Animation.decode()`). `GIF` will be removed in 4.0.

## 3.0.0
Expand Down
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too)
- PNGs (grayscale, RGB, indexed colors, 1-16 bit, interlaced) with and without alpha channels
- JPEGs (baseline and progressive; grayscale, RGB, CMYK)
- TIFFs (strips and tiles; uncompressed, LZW, deflate, PackBits and JPEG compressed; BigTIFF)
- JPEGs, TIFFs and PNGs are rotated as their Exif (or, for PNGs, XMP) orientation says
- [Decoding animations](https://imagescript.matmen.dev/classes/Animation.html#decode) (GIF and APNG) with all
- WebPs (lossy, lossless and with alpha; bit-exact with libwebp)
- JPEGs, TIFFs, PNGs and WebPs are rotated as their Exif (or, for PNGs, XMP) orientation says
- [Decoding animations](https://imagescript.matmen.dev/classes/Animation.html#decode) (GIF, APNG and WebP) with all
frames, durations and disposal modes
- [Rendering SVGs](https://imagescript.matmen.dev/classes/Image.html#rendersvg) (shapes, paths, gradients, strokes, clip paths,
masks, CSS; no text, images or filters)
Expand All @@ -45,8 +46,10 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too)
, [dominantColor](https://imagescript.matmen.dev/classes/Image.html#dominantcolor), ...)
- Encoding images as [PNGs](https://imagescript.matmen.dev/classes/Image.html#encode) (smallest lossless color type)
, [JPEGs](https://imagescript.matmen.dev/classes/Image.html#encodejpeg)
- Encoding animations as [GIF](https://imagescript.matmen.dev/classes/Animation.html#encodegif) and
[APNG](https://imagescript.matmen.dev/classes/Animation.html#encodeapng)
and lossless [WebPs](https://imagescript.matmen.dev/classes/Image.html#encodewebp)
- Encoding animations as [GIF](https://imagescript.matmen.dev/classes/Animation.html#encodegif),
[APNG](https://imagescript.matmen.dev/classes/Animation.html#encodeapng) and lossless
[WebP](https://imagescript.matmen.dev/classes/Animation.html#encodewebp)

---

Expand Down Expand Up @@ -84,7 +87,8 @@ The deflate/inflate implementation is derived from [fflate](https://github.com/1
[Fast CRC32 in JavaScript](https://gist.github.com/101arrowz/e58695f7ccfdf74f60ba22018093edea), both by Arjun Barrett.
The JPEG codec is based in part on the work of the Independent JPEG Group (libjpeg) and on
[pdf.js](https://github.com/mozilla/pdf.js), the glyph rasterizer is translated from
[font-rs](https://github.com/raphlinus/font-rs). See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for their licenses.
[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)
and reproduces [libwebp](https://chromium.googlesource.com/webm/libwebp)'s color conversion. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for their licenses.

---

Expand Down
70 changes: 70 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ following projects, under their own licenses. Each derived file says so in its h
| [libjpeg](https://ijg.org/) / [libjpeg-turbo](https://libjpeg-turbo.org/) by the Independent JPEG Group | `src/codecs/jpeg.ts` (IDCT, FDCT, upsampling, color conversion, progressive refinement, optimal Huffman tables) | IJG |
| [pdf.js](https://github.com/mozilla/pdf.js) (forked from [jpgjs](https://github.com/notmasteryet/jpgjs) by notmasteryet) | `src/codecs/jpeg.ts` (structure of the scan decoder) | Apache-2.0 |
| [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 |
| [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) |
| [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 |
| [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 |

## fflate, Fast CRC32 in JavaScript

Expand Down Expand Up @@ -72,3 +75,70 @@ The scan decoder of `src/codecs/jpeg.ts` follows the structure of pdf.js' `src/c
one decode function per scan type). The `Rasterizer` class in `src/render/text.ts` is a translation of font-rs'
`Raster` from Rust to TypeScript (`draw_line`, `draw_quad`, `accumulate`); its `cubic` method is adapted from
`draw_quad`. The full license text is in [licenses/Apache-2.0.txt](licenses/Apache-2.0.txt).

## RFC 6386, libwebp

The VP8 decoder follows the procedures of RFC 6386 and its reference decoder, its constant tables are extracted from
the RFC. To decode exactly like libwebp, the YUV to RGB conversion, the chroma upsampling and the blending of animation
frames reproduce libwebp's integer arithmetic.

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

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:

* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.

* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.

* Neither the name of Google nor the names of its contributors may
be used to endorse or promote products derived from this software
without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```

The RFC's reference decoder additionally comes with Google's patent grant for VP8 (RFC 6386 section 20.27).

## RFC 9649

The WebP container and the lossless (VP8L) codec follow RFC 9649, the short distance code table is taken from it.

```
Copyright (c) 2024 IETF Trust and the persons identified as authors of the code. All rights reserved.

Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
following conditions are met:

- Redistributions of source code must retain the above copyright notice, this list of conditions and the following
disclaimer.
- Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the
following disclaimer in the documentation and/or other materials provided with the distribution.
- Neither the name of Internet Society, IETF or IETF Trust, nor the names of specific contributors, may be used to
endorse or promote products derived from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
44 changes: 41 additions & 3 deletions src/animation.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import * as gifCodec from './codecs/gif.ts';
import * as png from './codecs/png.ts';
import * as webp from './codecs/webp.ts';
import { BinaryData, view } from './util/mem.ts';
import { Image, ResizeMode } from './image.ts';
import { Frame } from './frame.ts';
import { ImageType } from './image-type.ts';

// loop counts: ImageScript (like GIF) counts repetitions (-1 forever, 0 play once), APNG counts plays (0 forever)
// loop counts: ImageScript (like GIF) counts repetitions (-1 forever, 0 play once), APNG and WebP count plays (0 forever)
const toPlays = (loopCount: number): number => loopCount < 0 ? 0 : loopCount + 1;
const fromPlays = (plays: number): number => plays === 0 ? -1 : plays - 1;

Expand All @@ -19,7 +20,7 @@ function canvasFrames(width: number, height: number, frames: Array<{ pixels: Uin
}

/**
* An animation: an array of frames that can be decoded from and encoded to GIF and APNG.
* An animation: an array of frames that can be decoded from and encoded to GIF, APNG and WebP.
* Array methods like map and filter return plain arrays.
*/
export class Animation extends Array<Frame> {
Expand Down Expand Up @@ -149,6 +150,37 @@ export class Animation extends Array<Frame> {
return png.encodeAnimation(this.__apngFrames__(), this.width, this.height, toPlays(this.loopCount), compression);
}

/**
* Encodes the animation as lossless animated WebP
*/
async encodeWebP(): Promise<Uint8Array> {
this.__validate__();
if (!this.length) throw new Error('An animation needs at least one frame');
const width = this.width, height = this.height;

// WebP knows no "previous" disposal and stores offsets halved: such animations are stored as full frames
const fullFrames = this.some(f => f.disposalMode === 'previous' || (f.disposalMode === 'background' && (f.xOffset & 1 || f.yOffset & 1)));
if (fullFrames) {
const canvases = png.compositeAnimation({ width, height, frames: this.__apngFrames__() });
return webp.encodeAnimation(canvases.map(c => ({ x: 0, y: 0, width, height, duration: c.duration, dispose: true, pixels: c.pixels })), width, height, toPlays(this.loopCount));
}

const frames = Array.from(this, frame => {
let { xOffset: x, yOffset: y, width: w, height: h } = frame;
let pixels = new Uint8Array(frame.bitmap.buffer, frame.bitmap.byteOffset, frame.bitmap.byteLength);
// odd offsets: one transparent pixel more on the left/top (blending makes it invisible)
if (x & 1 || y & 1) {
const dx = x & 1, dy = y & 1;
const padded = new Uint8Array((w + dx) * (h + dy) * 4);
for (let row = 0; row < h; row++) padded.set(pixels.subarray(row * w * 4, (row + 1) * w * 4), ((row + dy) * (w + dx) + dx) * 4);
pixels = padded;
x -= dx; y -= dy; w += dx; h += dy;
}
return { x, y, width: w, height: h, duration: frame.duration, dispose: frame.disposalMode === 'background', pixels };
});
return webp.encodeAnimation(frames, width, height, toPlays(this.loopCount));
}

/** @internal frames in the APNG codec's terms (alpha blended, disposal 0 none, 1 background, 2 previous) */
__apngFrames__(): png.APNGFrame[] {
const dispose = [0, 0, 2, 1];
Expand All @@ -165,7 +197,7 @@ export class Animation extends Array<Frame> {
}

/**
* Decodes an animated GIF or APNG into fully composited frames (still images become a single frame)
* Decodes an animated GIF, APNG or WebP into fully composited frames (still images become a single frame)
* @param input The binary data to decode
* @param onlyExtractFirstFrame Whether to stop decoding after the first frame
*/
Expand All @@ -175,6 +207,12 @@ export class Animation extends Array<Frame> {

if (type === 'gif') return Animation.__fromGIF__(data, onlyExtractFirstFrame, Animation);

if (type === 'webp') {
const decoded = webp.decodeAnimation(data, onlyExtractFirstFrame);
const frames = canvasFrames(decoded.width, decoded.height, webp.composite(decoded));
return new Animation(frames, fromPlays(decoded.loopCount));
}

if (type === 'png') {
const decoded = await png.decodeAnimation(data, onlyExtractFirstFrame);
const frames = canvasFrames(decoded.width, decoded.height, png.compositeAnimation(decoded));
Expand Down
Loading
Loading