@@ -65,15 +65,6 @@ const ADAM7 = [[0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], [0, 2, 2,
6565 * @param data Raw pixel data, rows packed MSB first for depths below 8
6666 */
6767export async function encode ( data : Uint8Array , { text, width, height, channels, depth = 8 , level = 0 , palette, transparency, filter : filterRows = false } : EncodeOptions ) : Promise < Uint8Array > {
68- const rowBytes = Math . ceil ( width * channels * depth / 8 ) ;
69- let filtered : Uint8Array ;
70- if ( filterRows ) filtered = filter ( data , rowBytes , height , Math . max ( 1 , channels * depth / 8 ) ) ;
71- else {
72- // prefix every row with filter type 0 (none)
73- filtered = new Uint8Array ( height * ( rowBytes + 1 ) ) ;
74- for ( let y = 0 ; y < height ; y ++ ) filtered . set ( data . subarray ( y * rowBytes , ( y + 1 ) * rowBytes ) , y * ( rowBytes + 1 ) + 1 ) ;
75- }
76-
7768 const header = new Uint8Array ( 13 ) ;
7869 const view = new DataView ( header . buffer ) ;
7970 view . setUint32 ( 0 , width ) ;
@@ -85,7 +76,7 @@ export async function encode(data: Uint8Array, { text, width, height, channels,
8576 if ( palette ) chunks . push ( chunk ( PLTE , palette ) ) ;
8677 if ( transparency ) chunks . push ( chunk ( tRNS , transparency ) ) ;
8778 chunks . push ( ...textChunks ( text ) ) ;
88- chunks . push ( chunk ( IDAT , await compressAsync ( filtered , level ) ) ) ;
79+ chunks . push ( chunk ( IDAT , await compressRows ( data , width , height , channels , depth , level , filterRows ) ) ) ;
8980
9081 chunks . push ( chunk ( IEND , new Uint8Array ( 0 ) ) ) ;
9182 return concat ( [ HEAD , ...chunks ] ) ;
@@ -106,15 +97,12 @@ function textChunks(text: EncodeOptions['text']): Uint8Array[] {
10697 return chunks ;
10798}
10899
109- /**
110- * Filters every row with the paeth filter. Picking a filter per row (libpng's heuristic) costs twice as much and
111- * compresses within 0.1% of it for photos, graphics and gradients alike
112- */
113- function filter ( data : Uint8Array , rowBytes : number , height : number , bpp : number ) : Uint8Array {
114- const out = new Uint8Array ( height * ( rowBytes + 1 ) ) ;
100+ /** Filters rows y0 to y1 with the paeth filter */
101+ function filter ( data : Uint8Array , rowBytes : number , y0 : number , y1 : number , bpp : number ) : Uint8Array {
102+ const out = new Uint8Array ( ( y1 - y0 ) * ( rowBytes + 1 ) ) ;
115103
116- for ( let y = 0 ; y < height ; y ++ ) {
117- const row = y * rowBytes , o = y * ( rowBytes + 1 ) + 1 ;
104+ for ( let y = y0 ; y < y1 ; y ++ ) {
105+ const row = y * rowBytes , o = ( y - y0 ) * ( rowBytes + 1 ) + 1 ;
118106 out [ o - 1 ] = 4 ;
119107
120108 // the first row has no row above, the first pixel of every row no left neighbour
@@ -136,6 +124,39 @@ function filter(data: Uint8Array, rowBytes: number, height: number, bpp: number)
136124 return out ;
137125}
138126
127+ /** Prefixes rows y0 to y1 with filter type 0 (none) */
128+ function prefix ( data : Uint8Array , rowBytes : number , y0 : number , y1 : number ) : Uint8Array {
129+ const out = new Uint8Array ( ( y1 - y0 ) * ( rowBytes + 1 ) ) ;
130+ for ( let y = y0 ; y < y1 ; y ++ ) out . set ( data . subarray ( y * rowBytes , ( y + 1 ) * rowBytes ) , ( y - y0 ) * ( rowBytes + 1 ) + 1 ) ;
131+ return out ;
132+ }
133+
134+ const SAMPLE_ROWS = 32 , SAMPLE_STEP = 512 ;
135+
136+ /**
137+ * Filters (or just prefixes) the rows and compresses them. Filtered rows are paeth filtered, or not at all if that
138+ * compresses better: photos shrink by about a third filtered, but graphics often compress better unfiltered (their
139+ * repeated shapes stay the same bytes), by up to 40%. Decided by compressing samples both ways, 32 rows of every 512
140+ * (about 8% more time); picking a filter per row (libpng's heuristic) costs twice as much and does worse for graphics
141+ */
142+ async function compressRows ( data : Uint8Array , width : number , height : number , channels : number , depth : number , level : number , filterRows : boolean ) : Promise < Uint8Array > {
143+ const rowBytes = Math . ceil ( width * channels * depth / 8 ) , bpp = Math . max ( 1 , channels * depth / 8 ) ;
144+ if ( ! filterRows ) return compressAsync ( prefix ( data , rowBytes , 0 , height ) , level ) ;
145+ // (small images: the smaller of both)
146+ if ( height <= SAMPLE_ROWS ) {
147+ const [ unfiltered , filtered ] = await Promise . all ( [ compressAsync ( prefix ( data , rowBytes , 0 , height ) , level ) , compressAsync ( filter ( data , rowBytes , 0 , height , bpp ) , level ) ] ) ;
148+ return unfiltered . length < filtered . length ? unfiltered : filtered ;
149+ }
150+ const unfilteredSamples : Uint8Array [ ] = [ ] , filteredSamples : Uint8Array [ ] = [ ] ;
151+ for ( let y = Math . min ( ( SAMPLE_STEP - SAMPLE_ROWS ) >> 1 , ( height - SAMPLE_ROWS ) >> 1 ) ; y < height ; y += SAMPLE_STEP ) {
152+ const end = Math . min ( height , y + SAMPLE_ROWS ) ;
153+ unfilteredSamples . push ( prefix ( data , rowBytes , y , end ) ) ;
154+ filteredSamples . push ( filter ( data , rowBytes , y , end , bpp ) ) ;
155+ }
156+ const [ unfiltered , filtered ] = await Promise . all ( [ compressAsync ( concat ( unfilteredSamples ) , level ) , compressAsync ( concat ( filteredSamples ) , level ) ] ) ;
157+ return compressAsync ( unfiltered . length < filtered . length ? prefix ( data , rowBytes , 0 , height ) : filter ( data , rowBytes , 0 , height , bpp ) , level ) ;
158+ }
159+
139160function chunk ( type : number , body : Uint8Array ) : Uint8Array {
140161 const out = new Uint8Array ( body . length + 12 ) ;
141162 const view = new DataView ( out . buffer ) ;
@@ -281,8 +302,8 @@ const aligned = (rgba: Uint8Array): Uint8Array => rgba.byteOffset % 4 ? rgba.sli
281302
282303/**
283304 * Encodes 8-bit RGBA pixels into a PNG with the smallest lossless color type (see {@link pixelFormat}).
284- * Rows of non-palette images are paeth filtered (unless the data isn't compressed), palette indices aren't
285- * filtered (like libpng recommends)
305+ * Rows of non-palette images are paeth filtered where that compresses better (unless the data isn't compressed),
306+ * palette indices aren't filtered (like libpng recommends)
286307 */
287308export async function encodeRGBA ( rgba : Uint8Array , width : number , height : number , level : number , text ?: EncodeOptions [ 'text' ] ) : Promise < Uint8Array > {
288309 rgba = aligned ( rgba . subarray ( 0 , width * height * 4 ) ) ;
@@ -291,17 +312,6 @@ export async function encodeRGBA(rgba: Uint8Array, width: number, height: number
291312 return encode ( format . convert ( rgba , width , height ) , { width, height, level, text, channels, depth, palette, transparency, filter : level > 0 && ! palette } ) ;
292313}
293314
294- // filters (or just prefixes) the rows and compresses them
295- async function compressRows ( data : Uint8Array , width : number , height : number , channels : number , depth : number , level : number , filterRows : boolean ) : Promise < Uint8Array > {
296- const rowBytes = Math . ceil ( width * channels * depth / 8 ) ;
297- let filtered : Uint8Array ;
298- if ( filterRows ) filtered = filter ( data , rowBytes , height , Math . max ( 1 , channels * depth / 8 ) ) ;
299- else {
300- filtered = new Uint8Array ( height * ( rowBytes + 1 ) ) ;
301- for ( let y = 0 ; y < height ; y ++ ) filtered . set ( data . subarray ( y * rowBytes , ( y + 1 ) * rowBytes ) , y * ( rowBytes + 1 ) + 1 ) ;
302- }
303- return compressAsync ( filtered , level ) ;
304- }
305315
306316export interface APNGEncodeFrame {
307317 x : number ;
0 commit comments