Skip to content

Commit e511d22

Browse files
matmenclaude
andcommitted
decoding: pixel limit against decompression bombs, robustness fixes
- DecodeOptions.maxPixels / AnimationDecodeOptions.maxPixels (default 16383², like sharp): every decoder checks the size it read before allocating (PNG, APNG, JPEG, TIFF, WebP/VP8/VP8L, TGA, GIF); animations count every frame's canvas, GIFs also the frames' indices while parsing. A 250-byte JPEG of 65500² and a 115 kB GIF with 5000 frames on 4000x4000 killed the process before. - TIFF: raw samples at most 8 bytes per pixel of the limit, tiles at most 4 times the image (a damaged SamplesPerPixel took 6 GB). - JPEG: at most 4 components (each took memory the size of the image). - RangeErrors from damaged data become "Image: damaged or truncated image data" (cause kept); exceeded limits stay RangeErrors (LimitError). - PNG: rows unfiltered in place, Image.decode keeps the decoded pixels (8000x8000: 1272 -> 785 MB), still PNGs in Animation.decode decode like images (5.8 s -> 0.57 s), APNG frames that replace pixels copy rows. - blur/sharpen: infinite radii throw instead of hanging, box windows are summed in time independent of the radius (blur(1e9) took minutes). Found with fuzzing (damaged corpus images, crafted bombs per format, bad parameters to every public method); decoded pixels of the whole corpus are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ
1 parent 0fdc98e commit e511d22

17 files changed

Lines changed: 293 additions & 59 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,23 @@
3535
character, and decomposes characters the fonts lack where one has their parts, like HarfBuzz.
3636
- Fallback fonts are only loaded when the text needs them, and glyphs (also decoded and scaled emoji bitmaps) are
3737
kept per font and size across calls.
38+
- `DecodeOptions.maxPixels` and `AnimationDecodeOptions.maxPixels` (default 268,402,689, that's 16383², like sharp):
39+
larger images, and animations whose frames take more pixels together, throw a RangeError before anything that size
40+
is allocated. Tiny files declaring huge images (decompression bombs) could take gigabytes or kill the process: a
41+
250-byte JPEG of 65500x65500, a 115 kB GIF with 5000 frames on a 4000x4000 screen. `maxPixels: Infinity` lifts the
42+
limit. TIFFs also can't take more than 8 bytes of samples per pixel of the limit, or tiles much larger than the
43+
image.
3844

3945
### Performance
4046

4147
- Lossless WebP encoding is about 40% faster and decoding about 15% faster (1920×1080 photo: 735 instead of 1220 ms,
4248
82 instead of 95 ms), with identical output: the predictors run as one loop per mode instead of a switch per pixel,
4349
residual costs come from a table, and the LZ77 search hashes every pixel pair once.
4450

51+
- Decoding PNGs takes less memory (an 8000x8000 RGBA image: 785 instead of 1272 MB at most): the rows are unfiltered in
52+
place, and `Image.decode` keeps the decoded pixels instead of copying them. `Animation.decode` decodes still PNGs like
53+
images (10 times as fast for large ones), and composites APNG frames that replace pixels row by row.
54+
4555
### Fixes
4656

4757
- The outline of `Image.renderText` (`strokeWidth`) showed through translucent text, it now only shows around the
@@ -56,6 +66,11 @@
5666
of a RangeError, and text larger than 2^28 pixels throws.
5767
- Glyph outlines reaching far outside their bitmap rasterize correctly (the parts outside used to change the slope of
5868
the edges inside).
69+
- Damaged images throw "Image: damaged or truncated image data" (with the original RangeError as its cause) instead
70+
of RangeErrors from reading past their end. JPEGs with more than 4 components are rejected (each took memory the size
71+
of the image).
72+
- `blur` and `sharpen` hung with an infinite radius, they throw a RangeError now; huge finite radii take as long as
73+
small ones (`blur(1e9)` took minutes).
5974

6075
## 3.2.1
6176

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too)
2828
- WebPs (lossy, lossless and with alpha; bit-exact with libwebp)
2929
- TGAs (color mapped, truecolor and greyscale; uncompressed and run length encoded)
3030
- JPEGs, TIFFs, PNGs and WebPs are rotated as their Exif (or, for PNGs, XMP) orientation says
31+
- images larger than 268 megapixels (`maxPixels`) are rejected before decoding, so small files declaring huge images
32+
can't exhaust the memory
3133
- [Decoding animations](https://imagescript.matmen.dev/classes/Animation.html#decode) (GIF, APNG and WebP) with all
3234
frames, durations and disposal modes
3335
- [Rendering SVGs](https://imagescript.matmen.dev/classes/Image.html#rendersvg) (shapes, paths, gradients, strokes, clip paths,

‎src/animation.ts‎

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ import { ImageType } from './image-type.ts';
88
import { optimize, OptimizeOptions, OptimizedFrame } from './util/optimize.ts';
99
import { Metadata, resolveMetadata, pngText, exifData, gifComments } from './metadata.ts';
1010
import { isOptions } from './util/options.ts';
11+
import { MAX_PIXELS, decoding } from './util/limits.ts';
1112

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

1617
// frames decoded fully composited, as full canvas frames
17-
function canvasFrames(width: number, height: number, frames: Array<{ pixels: Uint8Array, duration: number }>): Frame[] {
18+
function canvasFrames(width: number, height: number, frames: Array<{ pixels: Uint8Array | Uint8ClampedArray, duration: number }>): Frame[] {
1819
return frames.map(({ pixels, duration }) => {
1920
const frame = new Frame(width, height, duration, 0, 0, Frame.DISPOSAL_BACKGROUND);
2021
// the composited frames are copies already, the frame can keep them
@@ -33,6 +34,12 @@ export interface AnimationOptions {
3334
export interface AnimationDecodeOptions {
3435
/** Whether to stop decoding after the first frame. Default: `false` */
3536
onlyFirstFrame?: boolean;
37+
/**
38+
* The most pixels to decode, of all frames together (every frame is a full canvas): larger animations throw a
39+
* RangeError before anything that size is allocated, so small files declaring huge animations (decompression
40+
* bombs) can't exhaust the memory. Default: `268402689` (16383², like sharp), `Infinity` for no limit
41+
*/
42+
maxPixels?: number;
3643
}
3744

3845
/** Options of {@link Animation.encodeWebP}, and those all animation encoders have */
@@ -330,31 +337,46 @@ export class Animation extends Array<Frame> {
330337
*/
331338
static decode(input: BinaryData, onlyExtractFirstFrame?: boolean): Promise<Animation>;
332339
static async decode(input: BinaryData, onlyFirstFrameOrOptions: boolean | AnimationDecodeOptions = false): Promise<Animation> {
333-
const onlyExtractFirstFrame = isOptions(onlyFirstFrameOrOptions) ? onlyFirstFrameOrOptions.onlyFirstFrame ?? false : onlyFirstFrameOrOptions;
340+
return decoding(() => Animation.__decode__(input, onlyFirstFrameOrOptions));
341+
}
342+
343+
/** @internal */
344+
private static async __decode__(input: BinaryData, onlyFirstFrameOrOptions: boolean | AnimationDecodeOptions): Promise<Animation> {
345+
const { onlyFirstFrame: onlyExtractFirstFrame = false, maxPixels = MAX_PIXELS } = isOptions(onlyFirstFrameOrOptions) ? onlyFirstFrameOrOptions : { onlyFirstFrame: onlyFirstFrameOrOptions };
334346
const data = view(input);
335347
const type = ImageType.getType(data);
336348

337-
if (type === 'gif') return Animation.__fromGIF__(data, onlyExtractFirstFrame, Animation);
349+
if (type === 'gif') return Animation.__fromGIF__(data, onlyExtractFirstFrame, Animation, maxPixels);
338350

339351
if (type === 'webp') {
340-
const decoded = webp.decodeAnimation(data, onlyExtractFirstFrame);
352+
const decoded = webp.decodeAnimation(data, onlyExtractFirstFrame, maxPixels);
341353
const frames = canvasFrames(decoded.width, decoded.height, webp.composite(decoded));
342354
return new Animation(frames, fromPlays(decoded.loopCount));
343355
}
344356

357+
// (still PNGs decode like images, keeping their orientation as it is like before)
358+
if (type === 'png' && !png.isAnimated(data)) {
359+
const image = await Image.decode(data, { maxPixels, applyOrientation: false });
360+
return new Animation(canvasFrames(image.width, image.height, [{ pixels: image.bitmap, duration: 0 }]), -1);
361+
}
345362
if (type === 'png') {
346-
const decoded = await png.decodeAnimation(data, onlyExtractFirstFrame);
363+
const decoded = await png.decodeAnimation(data, onlyExtractFirstFrame, maxPixels);
347364
const frames = canvasFrames(decoded.width, decoded.height, png.compositeAnimation(decoded));
348365
return new Animation(frames, decoded.frames.length > 1 ? fromPlays(decoded.plays) : -1);
349366
}
350367

351-
if (type) return new Animation([Frame.from(await Image.decode(data), 0)]);
368+
if (type) return new Animation([Frame.from(await Image.decode(data, { maxPixels }), 0)]);
352369
throw new Error('Unsupported image type');
353370
}
354371

355372
/** @internal */
356-
static __fromGIF__<T extends Animation>(data: Uint8Array, onlyExtractFirstFrame: boolean, Class: new (frames: Frame[], loopCount: number) => T): T {
357-
const decoded = gifCodec.decode(data, onlyExtractFirstFrame);
373+
static __fromGIF__<T extends Animation>(data: Uint8Array, onlyExtractFirstFrame: boolean, Class: new (frames: Frame[], loopCount: number) => T, maxPixels = MAX_PIXELS): Promise<T> {
374+
return decoding(() => Animation.__gifFrames__(data, onlyExtractFirstFrame, Class, maxPixels));
375+
}
376+
377+
/** @internal */
378+
private static __gifFrames__<T extends Animation>(data: Uint8Array, onlyExtractFirstFrame: boolean, Class: new (frames: Frame[], loopCount: number) => T, maxPixels: number): T {
379+
const decoded = gifCodec.decode(data, onlyExtractFirstFrame, maxPixels);
358380
const frames = gifCodec.composite(decoded).map(({ pixels, delay }) => ({ pixels, duration: 10 * delay }));
359381
return new Class(canvasFrames(decoded.width, decoded.height, frames), decoded.loopCount);
360382
}

‎src/codecs/gif.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { quantize } from './quantize.ts';
2+
import { MAX_PIXELS, PixelBudget, checkPixels } from '../util/limits.ts';
23

34
// ImageScript disposal modes (any, keep, previous, background) <-> GIF disposal methods (unspecified, none, background, previous)
45
const DISPOSAL_TO_GIF = [0, 1, 3, 2];
@@ -206,7 +207,7 @@ function writeSubBlocks(out: ByteWriter, data: Uint8Array): void {
206207
* @param {boolean} [onlyFirstFrame=false]
207208
* @return {{width: number, height: number, loopCount: number, frames: GIFFrame[]}}
208209
*/
209-
export function decode(data: Uint8Array, onlyFirstFrame = false): DecodedGIF {
210+
export function decode(data: Uint8Array, onlyFirstFrame = false, maxPixels = MAX_PIXELS): DecodedGIF {
210211
const reader = new ByteReader(data);
211212

212213
const signature = reader.string(6);
@@ -222,6 +223,8 @@ export function decode(data: Uint8Array, onlyFirstFrame = false): DecodedGIF {
222223
const frames: DecodedGIFFrame[] = [];
223224
let loopCount = 0;
224225
let control: GraphicControl | null = null;
226+
// the frames' color indices, and later a canvas per frame
227+
const budget = new PixelBudget(maxPixels);
225228

226229
try {
227230
parse();
@@ -230,6 +233,7 @@ export function decode(data: Uint8Array, onlyFirstFrame = false): DecodedGIF {
230233
if (err !== UNEXPECTED_END) throw err;
231234
}
232235

236+
checkPixels(width, height, maxPixels, Math.max(1, frames.length));
233237
return { width, height, loopCount, frames };
234238

235239
function parse(): void {
@@ -287,6 +291,7 @@ export function decode(data: Uint8Array, onlyFirstFrame = false): DecodedGIF {
287291

288292
const minCodeSize = reader.u8();
289293
const compressed = readSubBlocks(reader);
294+
budget.take(fw, fh);
290295
const indices = lzwDecode(compressed, minCodeSize, fw * fh);
291296

292297
const { dispose = 0, delay = 0, transparentIndex = -1 } = control || {};

‎src/codecs/jpeg.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* (Copyright 2014 Mozilla Foundation, Apache License 2.0). See THIRD_PARTY_NOTICES.md
66
*/
77

8+
import { MAX_PIXELS, checkPixels } from '../util/limits.ts';
89
import { orientation as exifOrientation } from './exif.ts';
910
import { concat } from '../util/mem.ts';
1011

@@ -60,6 +61,8 @@ interface Frame {
6061
export interface DecodeOptions {
6162
/** overrides the color transform detected from JFIF/Adobe markers (used by TIFF) */
6263
transform?: 'ycc' | 'rgb' | 'cmyk' | null;
64+
/** the most pixels to decode */
65+
maxPixels?: number;
6366
}
6467

6568
function clamp8(v: number): number {
@@ -79,7 +82,7 @@ function clamp8(v: number): number {
7982
* @param {{transform?: 'ycc'|'rgb'|'cmyk'}} [options] overrides the color transform detected from JFIF/Adobe markers (used by TIFF)
8083
* @return {{width: number, height: number, pixels: Uint8Array}}
8184
*/
82-
export function decode(data: Uint8Array, { transform }: DecodeOptions = {}): { width: number, height: number, pixels: Uint8Array, orientation: number } {
85+
export function decode(data: Uint8Array, { transform, maxPixels = MAX_PIXELS }: DecodeOptions = {}): { width: number, height: number, pixels: Uint8Array, orientation: number } {
8386
if (data[0] !== 0xff || data[1] !== 0xd8) throw new Error('JPEG: invalid signature');
8487

8588
const quantizationTables: Int32Array[] = [];
@@ -160,6 +163,7 @@ export function decode(data: Uint8Array, { transform }: DecodeOptions = {}): { w
160163
case 0xc0: case 0xc1: case 0xc2: { // SOF baseline, extended, progressive
161164
if (frame) throw new Error('JPEG: multiple frames are not supported');
162165
if (data[start] !== 8) throw new Error(`JPEG: ${data[start]}-bit precision is not supported`);
166+
checkPixels(u16(start + 3), u16(start + 1), maxPixels);
163167
frame = createFrame(marker === 0xc2, u16(start + 1), u16(start + 3), data.subarray(start + 5, end));
164168
break;
165169
}
@@ -235,6 +239,8 @@ function createFrame(progressive: boolean, height: number, width: number, compon
235239
}
236240

237241
if (!width || !sampling.length) throw new Error('JPEG: invalid dimensions');
242+
// each component takes coefficients for the whole image (grey, YCbCr/RGB and CMYK have up to 4)
243+
if (sampling.length > 4) throw new Error(`JPEG: ${sampling.length} components are not supported`);
238244

239245
const hmax = Math.max(...sampling.map(c => c.h));
240246
const vmax = Math.max(...sampling.map(c => c.v));

‎src/codecs/png.ts‎

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { crc32 } from './crc.ts';
2+
import { MAX_PIXELS, checkPixels } from '../util/limits.ts';
23
import { concat } from '../util/mem.ts';
34
import { encode as utf8, decode as utf8Decode } from '../util/utf8.ts';
45
import { orientation as exifOrientation, xmpOrientation } from './exif.ts';
@@ -559,7 +560,7 @@ async function decodeImageData(p: Parsed, chunks: Uint8Array[], width: number, h
559560

560561
const rowBytes = Math.ceil(pw * bitsPerPixel / 8);
561562
const size = ph * rowBytes;
562-
const pixels = unfilter(data, dataOffset, ph, rowBytes, bpp);
563+
const pixels = unfilter(data, dataOffset, ph, rowBytes, bpp, !interlace);
563564
dataOffset += size + ph;
564565

565566
toRGBA(pixels, rowBytes, pw, ph, rgba, { row: y => ((ys + y * dy) * width + xs) * 4, step: dx * 4 });
@@ -574,9 +575,10 @@ async function decodeImageData(p: Parsed, chunks: Uint8Array[], width: number, h
574575
* The orientation comes from the eXIf chunk, or, if there is none, from the Exif text ImageMagick wrote before eXIf
575576
* existed ("Raw profile type exif") or from the XMP packet
576577
*/
577-
export async function decode(array: Uint8Array): Promise<Decoded> {
578+
export async function decode(array: Uint8Array, maxPixels = MAX_PIXELS): Promise<Decoded> {
578579
const p = parse(array);
579580
const { width, height } = p.header;
581+
checkPixels(width, height, maxPixels);
580582
const pixels = await decodeImageData(p, p.idat, width, height);
581583
return { width, height, pixels, orientation: p.exif ? exifOrientation(p.exif) : await textOrientation(p.text) };
582584
}
@@ -610,9 +612,11 @@ export function isAnimated(array: Uint8Array): boolean {
610612
* a single frame
611613
* @return plays: 0 = forever
612614
*/
613-
export async function decodeAnimation(array: Uint8Array, onlyFirstFrame = false): Promise<{ width: number, height: number, plays: number, frames: APNGFrame[] }> {
615+
export async function decodeAnimation(array: Uint8Array, onlyFirstFrame = false, maxPixels = MAX_PIXELS): Promise<{ width: number, height: number, plays: number, frames: APNGFrame[] }> {
614616
const p = parse(array);
615617
const { width, height } = p.header;
618+
// every frame is composited onto a canvas of its own
619+
checkPixels(width, height, maxPixels, !p.animation || p.animationError || onlyFirstFrame ? 1 : Math.max(1, p.frames.length));
616620

617621
// broken animations show the default image, like browsers do
618622
if (!p.animation || p.animationError) {
@@ -647,18 +651,29 @@ export function compositeAnimation({ width, height, frames }: { width: number, h
647651
for (const f of frames) {
648652
const previous = f.dispose === 2 ? canvas.slice() : null;
649653

650-
for (let y = 0; y < f.height; y++)
654+
for (let y = 0; y < f.height; y++) {
655+
// replacing: whole rows
656+
if (f.blend === 0) {
657+
canvas.set(f.pixels.subarray(y * f.width * 4, (y + 1) * f.width * 4), ((f.y + y) * width + f.x) * 4);
658+
continue;
659+
}
651660
for (let x = 0; x < f.width; x++) {
652661
const s = (y * f.width + x) * 4, d = ((f.y + y) * width + f.x + x) * 4;
653662
const sa = f.pixels[s + 3], da = canvas[d + 3];
654-
if (f.blend === 0 || sa === 255 || da === 0) {
655-
if (f.blend === 0 || sa !== 0) canvas.set(f.pixels.subarray(s, s + 4), d);
663+
if (sa === 255 || da === 0) {
664+
if (sa !== 0) {
665+
canvas[d] = f.pixels[s];
666+
canvas[d + 1] = f.pixels[s + 1];
667+
canvas[d + 2] = f.pixels[s + 2];
668+
canvas[d + 3] = sa;
669+
}
656670
} else if (sa !== 0) {
657671
const u = sa * 255, v = (255 - sa) * da, al = u + v;
658672
for (let c = 0; c < 3; c++) canvas[d + c] = (f.pixels[s + c] * u + canvas[d + c] * v) / al | 0;
659673
canvas[d + 3] = al / 255 | 0;
660674
}
661675
}
676+
}
662677

663678
output.push({ pixels: canvas.slice(), duration: f.duration });
664679

@@ -718,8 +733,10 @@ function hexProfile(text: string): Uint8Array {
718733
return out;
719734
}
720735

721-
function unfilter(data: Uint8Array, offset: number, height: number, rowBytes: number, bpp: number): Uint8Array {
722-
const out = new Uint8Array(height * rowBytes);
736+
// undoes the row filters; in place writes the rows over the data itself (every row ends up before where its data was,
737+
// and the data is read ahead of what's written)
738+
function unfilter(data: Uint8Array, offset: number, height: number, rowBytes: number, bpp: number, inPlace = false): Uint8Array {
739+
const out = inPlace ? data.subarray(offset, offset + height * rowBytes) : new Uint8Array(height * rowBytes);
723740
const first = Math.min(bpp, rowBytes);
724741

725742
// the first row and the first pixel of every row have zero neighbours, those are handled by separate loops

‎src/codecs/tga.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { encode as utf8 } from '../util/utf8.ts';
2+
import { MAX_PIXELS, checkPixels } from '../util/limits.ts';
23

34
// Truevision TGA: color mapped, truecolor and greyscale images, uncompressed or run length encoded
45

@@ -91,10 +92,11 @@ function readColor(d: Uint8Array, o: number, bits: number, alphaBits: number, ou
9192
/**
9293
* Decodes a TGA into RGBA
9394
*/
94-
export function decode(d: Uint8Array): { width: number, height: number, pixels: Uint8Array } {
95+
export function decode(d: Uint8Array, maxPixels = MAX_PIXELS): { width: number, height: number, pixels: Uint8Array } {
9596
const h = readHeader(d);
9697
if (!h || !isTGA(d)) throw new Error('TGA: invalid header');
9798
const { width, height, depth } = h;
99+
checkPixels(width, height, maxPixels);
98100
const base = h.imageType & ~8, rle = (h.imageType & 8) !== 0;
99101
const bytesPerPixel = Math.ceil(depth / 8);
100102

‎src/codecs/tiff.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { decompressAsync } from './zlib.ts';
22
import * as jpeg from './jpeg.ts';
3+
import { MAX_PIXELS, checkPixels, checkBytes } from '../util/limits.ts';
34

45
const TYPE_SIZES: Record<number, number> = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 6: 1, 7: 1, 8: 2, 9: 4, 10: 8, 11: 4, 12: 8, 13: 4, 16: 8, 17: 8, 18: 8 };
56

@@ -46,7 +47,7 @@ interface SampleReader {
4647
(rowOffset: number, n: number): { raw: number, value: number };
4748
}
4849

49-
export async function decode(data: Uint8Array): Promise<{ width: number, height: number, pixels: Uint8Array, orientation: number }> {
50+
export async function decode(data: Uint8Array, maxPixels = MAX_PIXELS): Promise<{ width: number, height: number, pixels: Uint8Array, orientation: number }> {
5051
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
5152

5253
let little: boolean;
@@ -178,6 +179,11 @@ export async function decode(data: Uint8Array): Promise<{ width: number, height:
178179
return data.subarray(offset, Math.min(data.length, offset + count));
179180
};
180181

182+
checkPixels(width, height, maxPixels);
183+
// tiles may reach past the image, but not by much (a segment's buffer is that large), and samples can take more
184+
// memory than the pixels
185+
checkPixels(segmentWidth, segmentHeight, Math.max(4 * width * height, 1 << 20));
186+
checkBytes(Math.ceil(width * samplesPerPixel * bps / 8) * height, maxPixels);
181187
const pixels = new Uint8Array(width * height * 4);
182188

183189
// JPEG compressed segments decode straight to RGBA

0 commit comments

Comments
 (0)