Skip to content

hey - #3

Open
icedTet wants to merge 221 commits into
Tet-Dev:pullfrom
matmen:master
Open

hey#3
icedTet wants to merge 221 commits into
Tet-Dev:pullfrom
matmen:master

Conversation

@icedTet

@icedTet icedTet commented Aug 4, 2021

Copy link
Copy Markdown
Member

No description provided.

github-actions Bot and others added 30 commits October 25, 2026 14:03
TX_MODE_SELECT at effort 2: the best luma mode is also tried with transforms of
depth 1 and 2 (each predicted from the previous ones' reconstruction). BD-rate
at effort 2: photos -0.7%, readme -1.2%, svg -4%, ~+10% time. At effort 1 it
gave only ~-0.5% for +10-17% time, so it stays off there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
PARTITION_H/V for 64x64 to 16x16 blocks, tried only where the whole block and
its quarters cost within 0.9-1.3x of each other and at most one quarter splits
further (where nearly all of their gains were, measured per case). Effort 1
tries the halves whose luma means differ more, effort 2 both.

BD-rate: effort 1 photos -2.3..-2.5%, readme -1.9%, graphics -0.5..-0.9%,
~+20% time; effort 2 -1.9..-3.2%, ~+40% time. Trying both directions without
the gate gave -3% for +80% time at effort 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
encodeAVIF() and encodeJXL() take metadata like the other encoders. AVIFs
get an Exif item that describes the primary image (cdsc), JPEG XLs the
container with an Exif box; the codestream is unchanged. The AVIF handler
name is now empty, so software: '' leaves ImageScript out of the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
The rate-distortion score only grows with each term, so a mode whose
header bits (intra 4x4) or distortion (intra 16x16, chroma) already reach
the best score is dropped before the reconstruction, texture distortion or
rate is computed. Quantized intra 4x4 blocks without levels are the
prediction, without an inverse transform; tTransform keeps its sums in
locals. 23-28% faster at effort 3-6, output unchanged (140 hashes over 5
images x efforts 0-6 x 4 qualities, cwebp tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
The paeth filter shrinks photos by about a third, but graphics often
compress better unfiltered, since repeated shapes stay the same bytes:
the benchmark's graphic was 20% larger paeth filtered (25% at level 6),
potato.png 36-42%. 32 rows of every 512 are compressed both ways at the
target level and the smaller wins (images of at most 32 rows: the smaller
whole). Graphics get 17-29% smaller and faster, photos are unchanged and
take about 8% longer. encode() and the APNG frames share compressRows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
Effort 3 and up add the full rate-distortion search of the intra 4x4
modes: on a 1920x1080 photo at quality 75, effort 2 takes ~245 ms for
101.6 kB (38.87 dB), effort 4 ~905 ms for 96.6 kB (39.32 dB). The
default becomes 2 for images and animations; effort 4 still encodes like
cwebp's default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
3.6.0 wasn't installable 2.5 minutes after publishing, so the bench job
failed until it was rerun.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
encodeJXL({ quality }) encodes lossy JPEG XLs, quality mapping to the
Butteraugli distance like cjxl -q; without it images stay lossless. The
encoder follows libjxl's effort 3: XYB, 8x8 DCTs, the constant
quantization field of the distance with libjxl's global scale, LF
quantizer and dead zones, default matrices, block contexts and chroma
from luma, EPF iterations by distance. LF coefficients use the weighted
predictor with libjxl's fixed tree per channel, HF metadata the left
predictor, alpha (lossless) the gradient, in the global stream or the
pass groups.

The new entropy encoder (entropy-enc.ts) clusters contexts, writes the
context map and codes tokens with ANS (libjxl's count normalization and
histogram format; the alias table comes from the decoder's) or prefix
codes. Bit writer and image header moved out of fast-lossless.ts, the
lossless output is unchanged.

1920x1080 photo at d1: 240.8 kB at the PSNR of cjxl -e 3's 240.2 kB,
0.9 s. BD-rate vs cjxl -e 3: +0.8 to +2.9% (photos, screenshots); vs
-e 7: -1 to +10% on photos, +31-42% on screenshots. Every size, distance
and alpha case tried decodes in djxl within 1 of our decoder.

Also exports AVIFEncodeOptions, which index.ts was missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
encodeJXL({ quality, effort }): effort 1 (the default) chooses the
varblocks like cjxl -e 5 (libjxl's ProcessRectACS: the best 8x8
transform of each block, merged up to 64x64 where the estimated cost is
lower, with the masking of libjxl's initial quantization field), 2 also
tries the 4x8 and AFV transforms, 0 keeps 8x8 DCTs. Effort 1 and 2 also
compute chroma from luma per 64x64 tile (libjxl's fast least squares).

Forward transforms for every varblock type (transforms-enc.ts): the
decoder's DCTs forward, unrolled up to 64 points (scripts/jxl-fdct.py,
and idct64 for the decoder), libjxl's DCT2X2, DCT4X4 and IDENTITY, the
other 8x8 ones by inverting their inverse as a sparse matrix; all
roundtrip within 1e-7. Quantization, LF and token writing handle any
varblock (its order, contexts, non-zero prediction).

Measured on 4 images x 5 distances (BD-rate by SSIMULACRA2 / PSNR /
butteraugli): effort 1 vs cjxl -e 3 -5.6 / -6.0 / -11.4%, vs -e 5
-1.5 / +0.9 / +4.4%; effort 2 vs -e 5 -3.6%, vs -e 7 +1.3% (SSIMULACRA2).
libjxl's adaptive quantization of the HF, AdjustQuantBlockAC and
Gaborish were tried and made files larger at equal SSIMULACRA2.
1920x1080 photo: ~1 s at effort 0, ~5 s at 1, ~10 s at 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
The SATD keeps its 4x4 Hadamard transform in locals; the transform type
search of a block of one transform predicts once and restores the
prediction for each type (both output-identical over 46 configurations);
16x16 blocks whose coefficients take fewer than 30 bits aren't split at
effort 1 (BD -0.05%). CPU time at effort 1 -8% on 4 images x 5 qualities.

Tried and dropped: comparing the transform types by their error in the
transform domain (no faster), fewer CDEF strengths or loop filter levels,
one chroma candidate (each 5-10% faster for 0.2-0.5% larger files).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
Frames shown as they are (replacing the canvas, not referenced, straight
alpha, no black or spot channels) go from XYB to interleaved sRGB bytes
in one pass: linear light to bytes by 255 thresholds found by bisection
of byte(fround(srgbEncode(v))), which only grows with v, so the bytes are
exactly those of before. Gaborish carries its 3x3 window along the row
(the same order of operations); the edge preserving filter's passes of
2 tap pairs have an unrolled path for inner blocks.

2048x1536 d1: 0.87 -> 0.73 s, 4000x3000 d1: 3.1 -> 2.6 s; the 49
conformance and bench files decode to identical pixels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
The VP8L encoder (lossless WebPs and the alpha of lossy ones) now
- groups the main image's 16x16 blocks by their symbols (k-means by the
  bits their clusters' codes would take, 4-16 groups, from the blocks
  farthest apart) and writes an entropy image with a prefix group each,
  if that is estimated to be smaller;
- picks the color cache's size (0-10 bits) by the estimated bits of the
  literals and hits, all sizes in one pass;
- delays a match by a pixel if the next one starts a longer one;
- tries predicting images of 17-256 colors instead of indexing them, and
  doesn't subtract green from images without red and blue (alpha planes).

Sizes vs cwebp: lossless 113.7% -> 107.2% (5 images), lossy alpha at
effort 2 113% -> 94%, at effort 4 134% -> 111%; every file decodes
exactly in dwebp. Lossless encoding takes about 65% longer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
A prefix code with one used symbol that can't be a simple code (a length
code, >= 256) is written as a normal code with one non-zero length.
Decoders read such a code with zero bits per symbol, the encoder wrote
one, so every following bit was off by one. Only prefix groups (since
3.7.0) produce such histograms. It broke lossless WebPs and the alpha of
lossy ones, which most optimized animations have.

Fixes #82

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsU2GDnECphmYTjLB5UeZv
…ocks

Optimized lossy frames made each exactly unchanged pixel transparent.
Where most pixels change a little (cross-fades), only scattered pixels
stay the same: the alpha masks were speckled and could cost more than
the color data they saved, so optimized files got larger than
unoptimized ones (reported: 8.9 MB instead of 7.0 MB).

Like libwebp's WebPAnimEncoder (QualityToMaxDiff, FlattenSimilarBlocks),
lossy frames now
- count pixels whose channels changed by up to a tolerance from the
  quality (3 at quality 85, 1 at 100) as unchanged, for the frame
  rectangle too (compared with what's shown, so errors don't add up);
- make unchanged pixels transparent only in whole 8x8 blocks (colored
  like the average of what's shown), and are sparse whenever a block is.

Six cross-faded radar-like animations at quality 85: 2% larger to 13%
smaller than before, 7-46% smaller than unoptimized, PSNR 0.1 dB below
unoptimized (before: 0.8-1 dB). A layer whose color changes by 1-2 per
frame: 16 kB instead of 158 kB (unoptimized: 246 kB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsU2GDnECphmYTjLB5UeZv
Normal mode with an opacity went through __blend__, per channel in
floats with the blend function called for each. It is source over with
the source alpha scaled by the opacity: the fast path's formula. The
fast path also indexes by offsets incremented per pixel and copies
bytes instead of 32-bit words, which was no faster.

A 1100x680 layer (two thirds at alpha 150) onto an opaque image, median
of 100 runs: opacity 0.85 12.6 -> 7.6 ms, opacity 1 9.4 -> 7.6 ms; an
opaque layer 4.4 -> 4.2 ms. The results are byte-identical to __blend__
on random pixels at random opacities; at exact halves (opacity 0.5 of
opaque pixels) they now round like the fast path did, by 1 at most.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsU2GDnECphmYTjLB5UeZv
Like crop on a clone, without copying the whole image first (it was
done as new Image(w, h).composite(source, -x, -y)). Clipped like crop,
whose bounds it shares; on a frame it gives a plain image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsU2GDnECphmYTjLB5UeZv
The feature list read like a changelog (bit-exactness, sub-format
details, maxPixels, ports): the formats are a table now, the functions
a short list linking the documentation, the credits one paragraph (the
FreeType notice stays, its license asks for it; the full notices are in
THIRD_PARTY_NOTICES.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsU2GDnECphmYTjLB5UeZv
The README example (tests/readme.js) draws a frosted glass weather card
over an SVG landscape: renderSVG with gradients, region and blur for
the glass, a blurred shadow, composite with an opacity, roundCorners,
text in Open Sans and Source Sans 3 with Arabic, Devanagari and color
emoji fallbacks, and a chart of gradient bars. It replaces the profile
card with the photo and the badge SVGs only it used.

The old image stays as tests/targets/profile-card.png: the lossless JPEG
XL test compares it with cjxl's output and the fixture scripts crop it.
averageColor checks the new image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsU2GDnECphmYTjLB5UeZv
tests/targets/external.png (and external.jpg) are now a square of
bench/inputs/photo.jpg (Carlos Varela, CC BY 2.0, in the third party
notices), scaled to 638x638; profile-card.png, the old README image, is
replaced by screen.png, a settings window drawn with ImageScript (flat
areas, text, rounded corners). Everything made from them is written
again, by the fixture scripts and the tools they name:

- tests/avif: aomenc 3.8.2, references by libavif 1.4.2 (no libyuv, as
  avifdec -u bilinear; reproduces every reference of the old files)
- tests/jxl: cjxl/djxl 0.11.1 (the parts of the photo and the screen;
  the circle, patches, animations and conformance files are unchanged)
- tests/ico, tests/hdr, tests/qoi: scripts/format-fixtures.py
- tests/tiffs: scripts/tiff-fixtures.py, which now also writes the
  TIFFs Pillow writes (the photo scaled to 157x101 bicubic, as before).
  i16b stores v * 257: its high bytes, which ImageScript reads, were 0
- references: cwebp 1.6.0, cjxl -e 1 with AVX2 and without SIMD (qemu)
- targets: fisheye, flip, effects (OVERWRITE_TEST)

Every tool was checked against the old files first: they reproduce the
old references. Two thresholds depend on the photo: monochrome AVIF is
13% smaller instead of 30% (the photo has little color), and JPEG
quality 10 reaches 27.7 dB (as libjpeg does) instead of 28.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsU2GDnECphmYTjLB5UeZv
renderSVG paints with <pattern>: tiles in user space or relative to the
bounding box (patternUnits), content in either (patternContentUnits),
viewBox and preserveAspectRatio, patternTransform (ignored if it can't be
inverted), attributes and children through href chains. Like resvg, the
tile is rendered once in device resolution (its size rounded, at most
4M pixels) and repeated: bicubic (Mitchell, B = C = 1/3), or the nearest
pixel where it's only translated. Patterns painting their own content
paint nothing, decided up front in document order like usvg's
fix_recursive_patterns.

resvg's pattern tests: 32/33 pass (was 4; the other needs text), copied
to tests/svgs/resvg. Marker and image tests unchanged; filters 81 -> 71,
all of them invalid filters on pattern-filled shapes that resvg doesn't
render (they passed only because the pattern was missing; filters are
still ignored).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
The pattern rendering follows resvg's (the tile, its filtering) and
usvg's fix_recursive_patterns; tests/svgs/resvg holds resvg's test
cases. svg.ts's header moves above the imports, so the build keeps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
renderSVG draws marker-start/mid/end (inherited, and the marker shorthand
in CSS only, like resvg): orient (any angle unit, auto,
auto-start-reverse), markerUnits, refX/refY, markerWidth/markerHeight,
viewBox, clipped unless overflow is visible. Placement and orientation
are ported from usvg's marker.rs, on the segments as tiny-skia's path
builder makes them (repeated moves/closes merged, a move after a close).
Arcs now split into as many cubics as kurbo makes for usvg (tolerance
0.1), so mid markers sit at the same vertices.

resvg's marker tests: 61/64 (was 11; the others have text or images),
copied to tests/svgs/resvg. Patterns 32/33, filters 71 unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
renderSVG draws <image> with data URIs: rasters are decoded first (the
decoders may be asynchronous; GIFs' first frame, the rest through
Image.decode), then passed to the synchronous renderer by href; SVGs
(gzipped too, via a new synchronous gunzip) render as nested documents.
Fitted like usvg: missing or auto width/height from the image,
preserveAspectRatio (clipped with slice), image-rendering; sampled like
tiny-skia (bicubic, nearest where only translated, padded edges), sharing
the pattern's sampler. An image's bounding box is its rectangle for
objectBoundingBox clips and masks. External files and URLs aren't loaded.
Nested SVG elements count towards MAX_INSTANCES (shared budget), each
href is collected once: 7 levels of 20 gzipped self-copies (1.28e9
elements) stop after 1.3 s.

resvg's image tests: 17/56 by the suite's threshold (was 0), 37/56 with
the frame's pixels ignored (tiny-skia rounds edges to quarter pixels,
the frame's 3.75 px strokes differ by more than 24); most others load
files. Patterns 32/33, markers 61/64, filters 71 unchanged; a test
checks PNG, WebP, GIF, JPEG and SVGZ data URIs pixel-exactly at 1:1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants