Skip to content

4.0.0-rc.4

Pre-release
Pre-release

Choose a tag to compare

@matmen matmen released this 14 Nov 11:28
Immutable release. Only release title and notes can be modified.

ImageScript 4.0.0-rc.4

The fourth release candidate for ImageScript 4.0 is ready for evaluation. This is a major release with breaking API
changes from 3.x. Before upgrading, update to the latest 3.x release (3.20.3) and address its deprecation warnings, then
follow the migration guide.

This release candidate comes out of a full pre-release check of the public API, the packaging, the runtimes and about
370,000 fuzzed decodes. It fixes what that found, and makes the remaining numeric arguments behave like the rest.

Breaking changes since rc.3

  • Renamed: the size option of cropCircle() is fit ('contain' or 'cover'), like resize()'s. Out-of-range
    feathering (outside 0-1) throws instead of being clamped.
  • Numbers have to be numbers, everywhere: a numeric string (opacity('0.5'), quality: '50', scale('2'),
    frame.duration = '40'), null or a boolean where a number goes is a TypeError. 3.x converted them in some methods
    and rejected them in others; rc.3 still accepted a few. NaN and the infinities are a RangeError.
  • Values that did nothing useful throw: roundCorners({ radius }) below 0 (it was clamped to 0), fisheye({ strength })
    of 0 or less (an empty image), and renderText({ size: 0 }) (a 1x1 image).
  • Gradients blend transparency premultiplied, like CSS: a gradient from transparent to red is ff00007f in the
    middle, no longer a dark 7f00007f. Gradients between opaque colors are unchanged.
  • image.gainMap = map stores a copy, so images given one map no longer change each other's by resizing or cropping.
  • resize({ background }) is only typed with fit: 'contain' (it was ignored otherwise).
  • Animation.resize() and scale() size the canvas like Image does: sides are cut to whole pixels, and scale()
    never goes below one pixel.
  • Cut-off animations keep the frames that arrived whole, and throw when none did: a GIF cut before its first frame
    decoded as a blank image, and the last frame of a cut GIF, lossy WebP or lossy JPEG XL animation was half decoded.
    A cut APNG showed only its first frame.
  • A canvas that doesn't fit the format throws: GIF over 65535 pixels (70000 was written as 4464), and an animation
    without a canvas.

Security and robustness

  • SVG strokes with round joins took 9 s and 1 GB for a 17 KB path (2.7 GB for 50 KB): a join was a whole circle at every
    vertex of a flattened curve. Joins are wedges now (1.6 s and 220 MB), and a render stops with a LimitError once its
    shapes' edges cross the rows of the image more than 2^27 times (thousands of huge overlapping strokes took 38 s).
  • A million dashes (157 bytes of SVG) took 650 MB: dashes beside the image are left out and the rest are stroked in
    batches. Text of 20000 glyphs took 15 s and now 0.4 s, 8000 random curves 18 s and now 2.3 s.
  • TIFF files that ask for far more memory than they are (YCbCr subsampling of 65534x65535: 4 GB for 32 KB; 1024
    samples per pixel: 1 GB for 33 KB) are refused before anything is allocated.
  • Decoding an animation takes 1.3 times the result's memory, not 2.2 (APNG and WebP composite each frame as it is
    decoded). dropShadow() and outline() throw a LimitError for sizes an image can't have.
  • Damaged JPEG, TIFF, GIF and TGA headers are decoding errors, where some read missing bytes or threw a RangeError.

Fixes

  • Bundlers keep only what a page uses: import { Colors } bundled the whole library (201 kB minified); it is 3.4 kB now,
    because the build's dist/package.json no longer hides "sideEffects": false.
  • Animation.resize() changes a frame once when an animation lists it more than once, resizes frames that share an
    image at different offsets to their own sizes, and throws below one pixel.
  • Animation.decode() of a JPEG XL animation counts all frames against maxPixels.
  • resize({ fit: 'cover' }) and fill(fn) leave the image and its gain map untouched when they throw.
  • The animation encoders cut frames to the canvas like Animation.encode*() does with optimize: false; add() rejects
    its promise for invalid frames; Colors.gradient() with one color throws for NaN.
  • Documentation: the README, the migration guide and the doc comments match the behavior (counts, error types, defaults).

Performance

Against 3.20.3 and rc.3, one build per process on a 1920x1080 photo, nothing changed beyond noise except: animated WebP
decode is 4.6% slower than rc.3 (the price of the lower memory), and quantize() with its new default dithering is
3.3 times slower than 3.x's (dither: 'none' is equal).

Known limits

  • TypeScript 5.7 or newer is needed (older versions can't read the Uint8Array<ArrayBuffer> return types).
  • Single-file bundles (esbuild, webpack) and CDN builds served from another origin run without worker threads: the
    output is identical, only slower. Serve dist/util/worker.js next to the bundle for threads.
  • The default maxPixels (16383 x 16383) still allows a GIF of 300 bytes to allocate a 1 GB canvas.

Feedback on this release candidate is welcome: open an issue.