Node.js ve Sharp ile Görsel Optimizasyon Pipeline Kurulumu

Kalın JPEG yığını ve inceltilmiş kopyalar

İçerik ekipleri görselleri genellikle elle hazırlayarak siteye yükler: birisi Photoshop'ta yeniden boyutlandırır, başkası PNG veya JPEG kaydeder, bir diğeri CDN klasörüne atar. Birkaç kişi birlikte çalışınca tutarsız çıktılar kaçınılmaz hale gelir; 3 MB'lık fotoğraflar doğrudan yayına girebilir, bazı görseller 1200 piksel genişliğindeyken bazıları 3000 piksele ulaşır, WebP dönüşümü bazen yapılır bazen yapılmaz.

Node.js ve Sharp bu tür tekrarlayan dönüşümleri otomatikleştirmek için yaygın bir tercih. Sharp, libvips tabanlı bir görsel işleme kütüphanesidir ve Node.js ekosisteminde hız ile düşük bellek kullanımı açısından öne çıkar. Kurallar merkezi bir yerde tanımlandığı için kim dosya yüklerse yüklesin aynı çıktı üretilir; ekip ayarlarla uğraşmak yerine içerik üretimine odaklanır.

Aşağıda gerçek bir içerik ekibinin nasıl bir düzen kurduğunu, hangi kararları neden aldığını ve bu yaklaşımın ne zaman işe yaramadığını adım adım aktarıyoruz.

Senaryo: haftalık 80 görsel, tutarsız çıktılar

Beş kişilik bir editöryal içerik ekibini ele alalım. Her hafta yaklaşık 80 görsel siteye giriyor: makale kapakları, kategori görselleri, ürün fotoğrafları. Ekipten biri Lightroom kullanıyor, diğeri telefon kamerasından doğrudan yüklüyor, üçüncüsü Canva'dan PNG indiriyor. Dosya boyutları 200 KB ile 8 MB arasında değişiyor. CMS otomatik küçük resim üretiyor ama orijinal dosyayı olduğu gibi saklıyor.

Sorun birkaç ay sonra görünür hale geldi: sayfa ağırlıkları yükseldi, bazı makalelerin kapak görseli tam ekran açılışta yavaş yüklendi. Performans verileri incelendiğinde görsel yükleme sürelerinin toplam sayfa yükleme süresinin büyük bölümünü oluşturduğu ortaya çıktı. Her yükleyiciye ayrı ayrı kurallar tarif etmek hem zaman alır hem de uyumu garanti etmez; kaynak klasörü izleyip her dosyayı otomatik işleyen bir ara katman kurmak daha kalıcı.

"Biri görsel yüklediğinde ne olması gerektiğini" kodla ifade etmek gerekir. Node.js ve Sharp ile verilen yanıt tam burada başlar.

Sharp: libvips tabanlı Node.js görsel işleme kütüphanesi

Sharp, Node.js için geliştirilmiş açık kaynaklı bir görsel işleme kütüphanesidir. JPEG, PNG, WebP, AVIF, TIFF, GIF ve SVG dahil pek çok formatı okuyup dönüştürebilir. Yeniden boyutlandırma, kırpma, döndürme, renk profili dönüştürme, metadata sıyırma ve format değiştirme işlemlerini tek bir akışta gerçekleştirir.

Altta çalışan libvips kütüphanesi sayesinde Sharp, görüntüyü belleğe tam olarak yüklemek yerine akış (streaming) mantığıyla işler. Büyük dosyalarda bu fark belirgin olur: 20 MB'lık bir kaynak dosyayı dönüştürürken bellek kullanımı yönetilebilir düzeyde kalır. Kurulumu da basittir.

Basit bir dönüşüm şöyle görünür:

const sharp = require('sharp');

sharp('input.jpg')
  .resize(1200)
  .webp({ quality: 82 })
  .toFile('output.webp');

Sharp, 1200 piksel genişliğe sığacak şekilde yeniden boyutlandırır ve WebP olarak kaydeder. En boy oranı (aspect ratio) korunur; siz açıkça belirtmeden dikey boyut otomatik hesaplanır. Fonksiyon zinciri (method chaining) formatı okunabilirliği artırır: hangi işlemin hangi sırayla uygulandığını satır satır görebilirsiniz.

Proje yapısı ve kurulum

Pipeline için ayrı bir Node.js projesi açmak yerine mevcut altyapıya ek bir script eklemek genellikle daha pratik. Gereken paketler ikide: sharp ve yeni dosyaları izlemek için chokidar.

npm init -y
npm install sharp chokidar

Klasör yapısı sade tutulabilir:

/pipeline
  index.js        ← izleyiciyi başlatır
  processImage.js ← dönüşüm mantığı
  config.js       ← kurallar merkezi
/input            ← ekip buraya yükler
/output           ← işlenmiş dosyalar buraya düşer

config.js içinde genişlik, kalite ve format sabitleri tek yerde toplanır:

module.exports = {
  maxWidth: 1200,
  quality: 82,
  outputFormat: 'webp',
  outputDir: './output',
};

Kuralların tek bir dosyada toplanması bakım açısından önemli. Ekibin ihtiyacı değiştiğinde, örneğin maksimum genişlik 1400'e çıkarılmak istendiğinde, tüm scripti okumak yerine yalnızca bu dosyayı düzenlemek yeterli. config.js'i versiyon kontrol sisteminde takip etmek hangi ayarın ne zaman değiştiğini de kaydeder.

Klasör izleyici ve işleme akışı

Ekip görsel /input klasörüne attığı anda pipeline tetiklenmeli. chokidar bu amaç için kullanılan yaygın bir kütüphanedir; Node.js'in yerel fs.watch'ına göre çapraz platform uyumluluğu ve ek seçenekler sunar.

const chokidar = require('chokidar');
const processImage = require('./processImage');
const fs = require('fs');
const config = require('./config');

fs.mkdirSync(config.outputDir, { recursive: true });

const processing = new Set();

const watcher = chokidar.watch('./input', {
  ignored: /^\./,
  persistent: true,
  awaitWriteFinish: {
    stabilityThreshold: 800,
    pollInterval: 100,
  },
});

watcher.on('add', async (filePath) => {
  if (processing.has(filePath)) return;
  processing.add(filePath);
  await processImage(filePath);
  processing.delete(filePath);
});

awaitWriteFinish seçeneği önemli bir ayrıntı: büyük dosya kopyalanırken event tetiklenirse dosya henüz tam yazılmamış olabilir. Bu parametreler 800 ms boyunca değişim olmadığında dosyayı hazır kabul eder; yarım kopyalanan dosyaların işlenmesinin önüne geçer.

processing Set'i tekrar işleme sorununa karşı basit bir önlem. Chokidar bazı dosya sistemlerinde aynı dosya için birden fazla add eventi üretebilir; Set sayesinde devam eden bir işlem varsa ikincisi atlanır.

processImage.js içeriği şu işi yapar:

const sharp = require('sharp');
const path = require('path');
const config = require('./config');

async function processImage(filePath) {
  const ext = path.extname(filePath).toLowerCase();
  if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;

  const baseName = path.basename(filePath, ext);
  const outputPath = path.join(config.outputDir, `${baseName}.webp`);

  try {
    await sharp(filePath)
      .resize({ width: config.maxWidth, withoutEnlargement: true })
      .webp({ quality: config.quality })
      .toFile(outputPath);

    console.log(`İşlendi: ${path.basename(outputPath)}`);
  } catch (err) {
    console.error(`Hata (${path.basename(filePath)}): ${err.message}`);
  }
}

module.exports = processImage;

withoutEnlargement: true parametresi küçük bir ayrıntı ama pratikte önemli: gelen görsel zaten 800 piksel genişliğindeyse Sharp onu 1200'e büyütmez. Büyütme işlemi görsel kalitesini düşürür ve dosya boyutunu gereksiz artırır.

Kalite ve boyut dengesi: Sharp'ın WebP ayarları

WebP kalite değeri için 75-85 aralığı çoğu içerik türünde makul bir denge noktası. 82 değeri gözle fark edilmesi güç bir sıkıştırma uygular; ancak bu değer görsel içeriğine göre farklılık gösterir. Düz renkli ürün görselleri 75'te bile yeterli görünüm sağlarken tekstür yoğun manzara fotoğrafları veya ince geçişler içeren grafikler 85'in altında bozulmaya başlayabilir.

.webp({ quality: 82, effort: 4 })

effort parametresi sıkıştırma hızı ile dosya boyutu arasındaki dengeyi etkiler: 0 en hızlı ve en büyük çıktı, 6 en yavaş ve en küçük çıktı üretir. Pipeline'ın her gün yüzlerce dosya işlemesi gerekiyorsa effort: 2 veya effort: 3 daha makul bir seçim; boyut farkı küçük ama işlem süresi önemli ölçüde kısalır.

JPEG kaynaklı dosyalarda metadata sıyırma hem dosya boyutunu düşürür hem de gizlilik açısından değer taşır. GPS koordinatı içeren bir fotoğraf CMS'e yüklendiğinde bu veriyi herkese açık tutmak istemeyebilirsiniz:

await sharp(filePath)
  .resize({ width: config.maxWidth, withoutEnlargement: true })
  .withMetadata(false)
  .webp({ quality: config.quality })
  .toFile(outputPath);

withMetadata(false) tüm Exif, IPTC ve XMP verilerini siler. Renk profilini (ICC) korumak istiyorsanız withMetadata({ icc: true }) seçeneği yalnızca onu tutar.

Birden fazla boyut üretmek de aynı script içinde mümkün. Makale kapağı için 1200 piksel, liste küçük resmi için 400 piksel gibi çıktılar Promise.all ile paralel üretilebilir:

await Promise.all([
  sharp(filePath)
    .resize({ width: 1200, withoutEnlargement: true })
    .webp({ quality: 82 })
    .toFile(outputPath1200),
  sharp(filePath)
    .resize({ width: 400, withoutEnlargement: true })
    .webp({ quality: 75 })
    .toFile(outputPath400),
]);

Her boyut için Sharp kaynak dosyayı ayrı ayrı okuduğundan, büyük dosyalarda bu yaklaşım seri işlemeye kıyasla bellek kullanımını artırabilir. Küçük ve orta boyutlu dosyalarda fark ihmal edilebilir düzeyde kalır.

Ne zaman işe yarar, ne zaman gerekmez?

Yerel klasör izleyici, küçük ve orta ölçekli ekiplerde, tek bir makinede sürekli çalışan pipeline için uygun. Herkes aynı ağa bağlı paylaşılan bir klasörü kullanıyorsa ve script o makinede kesintisiz çalışıyorsa düzgün işler. Kurulum maliyeti düşüktür, dışarıdan bir servise bağımlılık yoktur ve aylık maliyet sıfırdır.

Şu durumlarda bu yöntem yetersiz kalır: birden fazla kişi farklı makinelerden dosya yüklüyorsa ve ortak bir ağ klasörü yoksa görsel hiçbir zaman /input'a düşmez. CMS entegrasyonu gerekiyorsa ve yükleme anında otomatik tetiklenecek bir hook yoksa, kullanıcıların ayrı bir klasöre dosya atmasını beklemek alışkanlık yerleşene kadar çalışmaz. Görsel işleme süresi kritikse ve kuyruk yönetimi şartsa, aynı anda yüzlerce dosya geldiğinde bu yaklaşım bellek sorununa yol açabilir.

Ölçek büyüdüğünde yerel script'i bir HTTP endpoint'e dönüştürmek ya da bir mesaj kuyruğuna (örneğin Redis + Bull) bağlamak mantıklı bir sonraki adım. Script'in kendisi değişmez; değişen sadece tetikleyici mekanizmasıdır.

Kurulum hataları ve dikkat edilmesi gereken noktalar

Sharp binary kurulumunda zaman zaman sorun çıkar. Sharp, platform özel (prebuilt) ikili dosyalar kullanır; CI/CD ortamında veya Docker container'ında npm install yapıldığında platform farkı derleme hatasına neden olabilir. Çözüm genellikle iki yoldan biriyle gelir: ya npm install --platform=linux --arch=x64 sharp ile hedef platforma özel binary önceden indirilir, ya da package.json içindeki optionalDependencies yerine dependencies'e taşınır.

Çıktı klasörünün var olmaması yaygın bir başlangıç hatasıdır. Sharp, hedef klasörü otomatik oluşturmaz; script başında fs.mkdirSync(config.outputDir, { recursive: true }) çalıştırılmadan toFile() çağrısı hata üretir. Yukarıdaki index.js örneğinde bu satır kasıtlı olarak en başa alınmıştır.

Giriş formatının kontrolünü atlamak da sıkça görülür. /input klasörüne zaman zaman .pdf, .psd veya .DS_Store gibi dosyalar düşebilir. processImage içindeki uzantı kontrolü bu durumu yönetir; bilinmeyen formatlar sessizce atlanır ve hata fırlatılmaz.

Script'in sürekliliği son önemli nokta. Node.js süreci çöktüğünde ya da sunucu yeniden başladığında izleyici durur. pm2 veya işletim sistemi servis yöneticisi (systemd, Windows Hizmetleri) ile script bir servis olarak kaydedilmezse pipeline farkında olmadan devre dışı kalabilir.

Pipeline'ı kurmak birkaç saatlik iş; asıl süreç iş akışını oturtmaktır. Ekibin /input klasörünü düzenli kullanması, hatalı dosyalar için bir geri bildirim mekanizmasının belirlenmesi ve çıktı kalitesinin ilk haftalarda gözden geçirilmesi gerekir. Kod bittikten sonra kalan iş insan koordinasyonudur.

Sharp'ın sunduğu seçenekler bu yazıdaki örnekten çok daha geniş: farklı boyutlar üretmek, watermark eklemek, renk uzayı dönüştürmek, AVIF formatı çıkarmak. Basit başlayıp ihtiyaç arttıkça genişletmek daha sağlıklı; ilk versiyonu karmaşık tutmak çoğu durumda sürdürülebilirliği zorlaştırır ve birlikte çalışan ekiplerin script'i sahiplenmesini güçleştirir.

Pipeline küçük bir araç olduğu için hata anında tespit edilir ve düzeltmek kolaydır. Ekibin görsel yükleme alışkanlığı standartlaştıkça bu script ikinci plana çekilir ve sessizce çalışır; tam da böyle araçların işlemesi gereken biçimde.