omeryanbas.com

Ömer Yanbaş

Genel Müdür, Ticofab Yazılım

PerformansWeb

PNG’nin AVIF’i yendiği yer: sert noktalardan oluşan görseller

Kayıplı codec'ler fotoğraf için tasarlandı. Tram noktalı bir görselde indexed PNG her genişlikte AVIF'i yendi; bunu bilmenin tek yolu ölçmek.

Standart tavsiye AVIF sun, WebP'ye düş, eski tarayıcılar için JPEG bırak şeklinde. Fotoğraf için bu tavsiye doğru ve kazanç gerçek. Beyaz kâğıt üzerine sert siyah noktalardan oluşan bir görselde ise aynı resmi on bir farklı kodlamada ölçtüm ve modern formatlar hepsini kaybetti, bazılarında sekiz kat farkla. Denetim aracı yine de AVIF istedi, çünkü denetim araçları görselin fotoğraf olduğunu varsayar.

Gerçekte ne oluyor

AVIF ve WebP kayıplı dönüşüm codec'leridir. Görseli bloklara böler, her bloğu frekans katsayılarına çevirir, gözün en az fark edeceği katsayıları atar ve çoğu zaman renk kanallarını da altörnekler. Bu, komşu piksellerin birbirinden azıcık farklı olduğu bir fotoğrafın çok iyi bir modelidir.

Beyazdan siyaha bir basamak bunun tam tersi. Keskin bir kenarı üretmek yüksek frekanslı katsayıları gerektirir, ki encoder'ın atmak üzere kurulduğu katsayılar tam olarak onlardır. Encoder'ın iki seçeneği kalır ve ikisi de bizim için kötüdür: katsayıları tutar ve dosya büyür, ya da atar ve kenar yumuşayıp çınlar.

Tram ekran baştan sona kenardır. Her mürekkep noktasının bir sınırı vardır ve enine 140 hücrede bunlardan on binlerce olur. Aynısı çizgi çalışmaları, metin ekran görüntüleri ve QR kodlar için de geçerli; QR'da yumuşamış bir kenar yalnızca çirkin değil, okunamaz demek olabiliyor.

Indexed PNG tersten çalışır. Görsel birkaç renklik bir palete ve piksel başına bir indekse indirilir, satırlar filtrelenerek tekrar eden değerler sıfıra çevrilir ve DEFLATE bu dizileri sıkıştırır. Geniş düz alanlar ve tekrar eden desenler onun en kötü değil en iyi durumudur, üstelik sonuç birebirdir.

Nasıl görülür

Bunun üzerine akıl yürütmeyin, kodlayın. Kısa bir betik tek kaynaktan bütün adayları üretip boyutları yazar:

import fs from 'node:fs/promises';
import sharp from 'sharp';

const base = sharp(await fs.readFile('tram-720.png')).flatten({ background: '#ffffff' }).grayscale();

const candidates = {
  'png indexed 8':  base.clone().png({ palette: true, colours: 8, dither: 0, compressionLevel: 9 }),
  'png indexed 2':  base.clone().png({ palette: true, colours: 2, dither: 0, compressionLevel: 9 }),
  'png truecolour': base.clone().png({ palette: false, compressionLevel: 9 }),
  'webp lossless':  base.clone().webp({ lossless: true, effort: 6 }),
  'webp q90':       base.clone().webp({ quality: 90, effort: 6 }),
  'avif lossless':  base.clone().avif({ lossless: true, effort: 6 }),
  'avif q75':       base.clone().avif({ quality: 75, effort: 6 }),
  'avif q50':       base.clone().avif({ quality: 50, effort: 6 }),
};

for (const [name, pipeline] of Object.entries(candidates)) {
  const buf = await pipeline.toBuffer();
  console.log(name.padEnd(16), String(buf.length).padStart(9));
}

720 x 720 bir tram portre için sonuç şu oldu:

kodlamabaytboyut
indexed PNG, 2 renk20.25319,8 KB
AVIF kalite 50117.452114,7 KB
WebP kayıpsız123.556120,7 KB
indexed PNG, 8 renk132.197129,1 KB
AVIF kalite 75195.280190,7 KB
truecolour PNG200.484195,8 KB
AVIF kalite 90207.005202,2 KB
WebP kalite 75254.772248,8 KB
JPEG kalite 90267.724261,4 KB
WebP kalite 90337.062329,2 KB
AVIF kayıpsız1.093.1791.067,6 KB

Bu tabloda üzerinde durmaya değer üç şey var. Kayıpsız AVIF, aynı pikselleri tutan PNG'nin sekiz katından büyük. WebP kalite 90, sıkıştırılmamış görünen PNG'den hem büyük hem kötü, çünkü noktalar yumuşamış. PNG'yi geçen tek kayıplı ayar AVIF kalite 50 ve o kalitede tram ekran gözle görülür biçimde gri bulamaca dönüşüyor, yani tram kullanmanın anlamı kalmıyor.

Aynı düzen bütün srcset boyunca sürüyor ve nokta sayısı arttıkça kötüleşiyor:

genişlikindexed PNGAVIF kalite 75
40049,4 KB51,5 KB
56093,0 KB121,9 KB
720129,1 KB190,7 KB
960205,6 KB277,1 KB

Çözüm

İlk tablodaki asıl kazanç aslında bir codec değil. Sekiz palet renginden iki renge inmek dosyayı 129 KB'dan 20 KB'a düşürdü, ki bu masadaki hiçbir format tercihinden daha büyük bir tasarruf. Sekiz renk, nokta kenarlarında biraz yumuşatma satın alıyor; bunun altı katı bayta değip değmediği artık önünüzde bir sayı varken verebileceğiniz bir karar.

Yani kural, site başına değil, build'de görsel sınıfı başına seçmek:

  • Fotoğraflar: AVIF, sonra WebP, sonra JPEG. Modern formatların itibarını kazandığı yer burası.
  • Tram, dither'lı ve iki tonlu görseller, çizgi çalışmaları, diyagramlar: dithering kapalı indexed PNG ve görselin kaldırabildiği kadar küçük palet.
  • Metin ya da arayüz ekran görüntüleri: indexed PNG, ölçümde küçük çıkıyorsa WebP kayıpsız.
  • QR kod, barkod ve makinenin okuduğu her şey: yalnızca indexed PNG, hiçbir kalitede kayıplı codec yok.

Genişlikleri de yuvarlak sayı listesinden değil layout'tan seçin. Her breakpoint'te render edilen CSS genişliğini ölçün, device pixel ratio için bir ve iki ile çarpın ve tam olarak onları üretin. Gerisi ölü yük, ki bu da responsive images denetiminin size aslında söylediği şeylerden biri.

Tramda gözden kaçması kolay bir kısıt daha var. Her genişliği aynı hücre sayısıyla üretin ki noktalar incelmek yerine görselle birlikte ölçeklensin. Hücreleri sabit piksel kalan bir tram, küçük boyutlarda düz griye döner ve bütün etki kaybolur:

for (const width of [400, 560, 720, 960]) {
  const rgba = await halftone(source, { size: width, cells: 140 });  // sabit olan hücre sayısı, adım değil
  const buf = await sharp(rgba).flatten({ background: '#ffffff' }).grayscale()
    .png({ palette: true, colours: 8, dither: 0, compressionLevel: 9 }).toBuffer();
}

PNG kazanıyorsa düz bir img elementini srcset ile gönderin. Tek source saran bir picture elementi markup'ta gürültüdür ve senkron tutulacak bir şey daha demektir.

Çalıştığını nasıl doğrularsınız

Build kaybeden kodlamayı yayına almayı reddetsin. Dört satır ve hiç eskimiyor:

const encoded = Object.fromEntries(await Promise.all(
  Object.entries(candidates).map(async ([k, p]) => [k, await p.toBuffer()])));
const best = Object.entries(encoded).sort((a, b) => a[1].length - b[1].length)[0];
if (best[0] !== chosen) throw new Error(`${best[0]} secilen ${chosen} kodlamasindan kucuk`);

Sonra toplama bakın, çünkü okuyucunun bedelini ödediği birim sayfanın kendisi:

find dist/assets/img -type f | xargs ls -l | awk '{ t += $5 } END { printf "%.1f KB gorsel\n", t / 1024 }'

Modern format uyarısının devam etmesini bekleyin. Sorun değil: denetim bir emir değil, bir şeyi kontrol etme çağrısıdır ve artık cevabı veren tablo elinizde. Aynısı neredeyse bütün genel performans kuralları için geçerli, optimize etmeden önce ölçmenin yerini korumasının sebebi de bu.

Nelere dikkat etmeli

  • 256'dan fazla renk içeren bir indexed PNG sessizce truecolour PNG'ye döner ve bütün avantaj kaybolur. Çıktının renk tipini doğrulayın ya da en azından bayt boyutunu bir eşiğe karşı kontrol edin.
  • Dithering'i kapatın. Dither'lı bir renk indirgeme, düz alanların içine tek tek pikseller serper; bu da PNG'nin dayandığı tekrar dizilerini bozar ve dosyayı orijinalinden büyük yapabilir.
  • Alfa gerçek bir fark. AVIF yumuşak alfa kanalını iyi taşır; indexed PNG şeffaflığı palet girdisi ya da tRNS chunk'ı olarak tutar, ki bu kesilmiş bir şekil için yeterli, yumuşak bir gölge için yanlıştır.
  • Sunucunun PNG'yi bir kez daha sıkıştırmasına izin vermeyin. Dosya zaten DEFLATE; üzerine gzip ya da brotli, birkaç bayt eklemek için CPU harcar.
  • Makinenin okuduğu bir görseli bakarak değil decode ederek değerlendirin. Kalite 90'daki bir QR kod kusursuz görünüp loş ışıkta telefon kamerasında okunmayabilir.
  • AVIF kodlaması yüksek effort ayarlarında yavaş. Her commit'te onlarca görsel kodlayan bir build varsa build süresini de ölçün.

Buradaki asıl genelleme görsel formatlarıyla değil, varsayılan bir kuralın nereden geldiğiyle ilgili. "Modern format sun" kuralı fotoğraflardan türetildi, çünkü web'deki baytların neredeyse tamamı fotoğraf; bu yüzden de iyi bir tavsiye. Görseliniz fotoğraf olmadığı anda türetim geçerliliğini yitirir ve başa dönersiniz: bu görselin içinde gerçekten ne var ve hangi sıkıştırıcı onun için yapıldı. Hepsini kodlayıp boyutlara bakmak on dakika sürüyor ve cevap çoğu zaman herkesin demode saydığı format oluyor. Düz bir statik build'in ağırlıkta kazanmaya devam etmesinin sebebi de bu: her asset kararı bir kez, görünür biçimde ve sayılar ekranda dururken veriliyor.

Sorular ve cevaplar

AVIF neden bazen PNG'den büyük çıkıyor?
AVIF piksel bloklarını frekans katsayıları olarak kodlar; bu, fotoğraftaki yumuşak geçişler için verimli, beyazdan siyaha bir basamak için verimsizdir. Sert noktalardan oluşan bir görsel baştan sona basamaktır, yani encoder ya onları keskin tutmak için çok bit harcar ya da bulandırır. Indexed PNG aynı görseli palet indeksleri olarak saklar ve DEFLATE ile sıkıştırır; aynı pikselden oluşan uzun diziler onun en iyi olduğu durumdur.
Hangi görseller PNG kalmalı?
Az renkli ve sert sınırlı olan her şey: tram ve dither'lı görseller, çizgi çalışmaları ve diyagramlar, metin ya da arayüz ekran görüntüleri, SVG olmayan logolar ve QR kod, barkod gibi makinenin okuduğu görseller. Bunlarda indexed PNG genelde daha küçük, her zaman birebir ve format fallback'ine gerek kalmadan her yerde decode edilebilir.
Yine de AVIF ve WebP üretmeli miyim?
Üretin, ölçün ve yalnızca kazandıkları görsellerde yayına alın. Fallback'ten büyük bir source taşıyan picture elementi, karşılığında hiçbir şey almadan hem bayt hem karmaşıklık ekler. Kodlama kararını build'de görsel görsel vermek, fotoğraflarda modern formatın faydasını almanızı ve bunun bedelini diğer her şeyde ödememenizi sağlar.
srcset genişliklerini nasıl seçerim?
Yuvarlak sayı listesinden değil, layout'tan. Her breakpoint'te görselin render edilen CSS genişliğini ölçün, device pixel ratio için bir ve iki ile çarpın ve tam olarak o genişlikleri üretin. Layout'un hiçbir zaman isteyemeyeceği genişlikler hem markup'ta hem build'de ölü yüktür.