WebAssembly ile Tarayıcıda Ses ve Video Codec Geliştirme

Tarayıcıda gerçek zamanlı ses ve video işleme yapmak, yıllarca JavaScript’in performans sınırlarına takılıp kaldı. FFmpeg komutlarını sunucuya göndermek, sonucu beklemek, kullanıcıya döndürmek… Bu döngü hem yavaş hem de kaynak tüketimini ikiye katlıyordu. WebAssembly bu denklemi tamamen değiştirdi. Artık C ile yazılmış bir codec’i derleyip doğrudan tarayıcıda çalıştırmak mümkün ve bu sadece teorik bir iyileştirme değil, production ortamında ölçülebilir fark yaratan bir yaklaşım.

WASM Codec Geliştirmeye Neden İhtiyaç Duyuyoruz?

Klasik web mimarisinde video dönüştürme işi sunucuya aitti. Kullanıcı bir dosya yükler, sunucu FFmpeg ile işler, encode edilmiş dosya geri gönderilir. Bu akışın sorunları somut:

  • Bant genişliği israfı: Ham video dosyası hem upload hem download eder
  • Sunucu yükü: CPU yoğun codec işlemleri tüm kullanıcılar için merkezi bir darboğaz oluşturur
  • Gecikme: Büyük dosyalarda işlem süresi dakikaları bulabilir
  • Ölçekleme maliyeti: Eşzamanlı kullanıcı sayısı arttıkça encode farm kurma zorunluluğu

WASM ile bu işi istemciye taşıdığınızda, kullanıcının kendi CPU’su işi yapıyor. Sunucu sadece sonuç dosyayı alıyor ya da hiç almıyor, direkt tarayıcıda export yapılıyor.

Ortamı Hazırlamak: Emscripten Toolchain

WASM codec geliştirmenin temeli Emscripten. C/C++ kodunu .wasm binary’sine ve buna eşlik eden JavaScript glue koduna dönüştürür.

# Emscripten SDK kurulumu
git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install latest
./emsdk activate latest
source ./emsdk_env.sh

# Kurulumu doğrula
emcc --version
# emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 3.1.x

Geliştirme ortamı için Docker kullanmak çok daha temiz bir workflow sağlar, özellikle CI/CD pipeline’larında:

# Emscripten Docker image ile derleme
docker pull emscripten/emsdk:latest

# Proje dizinini mount ederek derleme
docker run --rm 
  -v $(pwd):/src 
  -w /src 
  emscripten/emsdk:latest 
  emcc src/codec.c 
    -O3 
    -o dist/codec.js 
    -s WASM=1 
    -s EXPORTED_FUNCTIONS='["_encode_frame","_decode_frame","_init_codec"]' 
    -s EXPORTED_RUNTIME_METHODS='["ccall","cwrap"]' 
    -s ALLOW_MEMORY_GROWTH=1 
    -s INITIAL_MEMORY=67108864

ALLOW_MEMORY_GROWTH=1 flag’i kritik önem taşıyor. Büyük video dosyalarında bellek ihtiyacı başlangıçta tahmin edilemez, bu yüzden dinamik büyümeye izin vermek gerekiyor.

Basit Bir PCM Audio Encoder Yazmak

Ses codec’lerine basit bir örnekle başlayalım. PCM verisi alıp basit bir mu-law (G.711) encode işlemi yapan C fonksiyonu:

# Proje yapısı oluştur
mkdir -p wasm-codec/{src,dist,js,test}
cat > wasm-codec/src/audio_codec.c << 'EOF'
#include <stdint.h>
#include <stdlib.h>
#include <math.h>
#include <emscripten/emscripten.h>

#define MULAW_MAX 32767
#define MULAW_BIAS 132

EMSCRIPTEN_KEEPALIVE
uint8_t pcm_to_mulaw(int16_t sample) {
    int sign = (sample < 0) ? 0x80 : 0;
    if (sample < 0) sample = -sample;
    if (sample > MULAW_MAX) sample = MULAW_MAX;
    sample += MULAW_BIAS;
    
    int exponent = 7;
    int exp_mask = 0x4000;
    while (!(sample & exp_mask) && exponent > 0) {
        exponent--;
        exp_mask >>= 1;
    }
    
    int mantissa = (sample >> (exponent + 3)) & 0x0F;
    return ~(sign | (exponent << 4) | mantissa);
}

EMSCRIPTEN_KEEPALIVE
int encode_pcm_buffer(int16_t* input, uint8_t* output, int length) {
    for (int i = 0; i < length; i++) {
        output[i] = pcm_to_mulaw(input[i]);
    }
    return length;
}

EMSCRIPTEN_KEEPALIVE
void* allocate_buffer(int size) {
    return malloc(size);
}

EMSCRIPTEN_KEEPALIVE
void free_buffer(void* ptr) {
    free(ptr);
}
EOF

Bu C kodunu WASM’a derleyelim:

cd wasm-codec

emcc src/audio_codec.c 
  -O3 
  -o dist/audio_codec.js 
  -s WASM=1 
  -s EXPORTED_RUNTIME_METHODS='["ccall","cwrap","HEAPU8","HEAP16"]' 
  -s EXPORTED_FUNCTIONS='["_encode_pcm_buffer","_allocate_buffer","_free_buffer","_malloc","_free"]' 
  -s ALLOW_MEMORY_GROWTH=1 
  -s MODULARIZE=1 
  -s EXPORT_NAME='AudioCodecModule' 
  -lm

ls -lh dist/
# audio_codec.js  audio_codec.wasm

JavaScript tarafında bu modülü kullanmak:

cat > js/audio_processor.js << 'EOF'
import AudioCodecModule from '../dist/audio_codec.js';

class AudioProcessor {
  constructor() {
    this.module = null;
    this.ready = false;
  }

  async init() {
    this.module = await AudioCodecModule();
    this.ready = true;
    console.log('Audio codec WASM modülü hazır');
  }

  encodePCM(float32Array) {
    if (!this.ready) throw new Error('Modül henüz yüklenmedi');
    
    const M = this.module;
    const sampleCount = float32Array.length;
    
    // Float32 -> Int16 dönüşümü
    const int16Data = new Int16Array(sampleCount);
    for (let i = 0; i < sampleCount; i++) {
      int16Data[i] = Math.max(-32768, Math.min(32767, float32Array[i] * 32768));
    }
    
    // WASM belleğine kopyala
    const inputPtr = M._malloc(sampleCount * 2);
    const outputPtr = M._malloc(sampleCount);
    
    M.HEAP16.set(int16Data, inputPtr / 2);
    
    // Encode et
    const encodedLength = M._encode_pcm_buffer(inputPtr, outputPtr, sampleCount);
    
    // Sonucu geri al
    const result = new Uint8Array(M.HEAPU8.buffer, outputPtr, encodedLength);
    const output = new Uint8Array(result);
    
    // Belleği temizle
    M._free(inputPtr);
    M._free(outputPtr);
    
    return output;
  }
}

export default AudioProcessor;
EOF

FFmpeg.wasm ile Video Codec Entegrasyonu

Sıfırdan video codec yazmak yerine FFmpeg’i WASM’a derlenmiş haliyle kullanmak production için çok daha gerçekçi bir yaklaşım. @ffmpeg/ffmpeg paketi tam da bunu yapıyor:

# Proje kurulumu
mkdir video-codec-demo && cd video-codec-demo
npm init -y
npm install @ffmpeg/ffmpeg @ffmpeg/util

# Vite ile dev server
npm install -D vite

Multi-threaded FFmpeg.wasm için COOP/COEP header’ları zorunlu. Bu header’lar olmadan SharedArrayBuffer çalışmıyor:

# vite.config.js
cat > vite.config.js << 'EOF'
import { defineConfig } from 'vite';

export default defineConfig({
  server: {
    headers: {
      'Cross-Origin-Opener-Policy': 'same-origin',
      'Cross-Origin-Embedder-Policy': 'require-corp',
    },
  },
  optimizeDeps: {
    exclude: ['@ffmpeg/ffmpeg', '@ffmpeg/util'],
  },
});
EOF

Gerçek bir video transcode uygulaması:

cat > src/video_transcoder.js << 'EOF'
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile, toBlobURL } from '@ffmpeg/util';

const BASE_URL = 'https://unpkg.com/@ffmpeg/[email protected]/dist/esm';

export class VideoTranscoder {
  constructor() {
    this.ffmpeg = new FFmpeg();
    this.loaded = false;
  }

  async load(onProgress) {
    if (this.loaded) return;

    this.ffmpeg.on('log', ({ message }) => {
      console.debug('[FFmpeg]', message);
    });

    this.ffmpeg.on('progress', ({ progress, time }) => {
      if (onProgress) onProgress(Math.round(progress * 100), time);
    });

    await this.ffmpeg.load({
      coreURL: await toBlobURL(`${BASE_URL}/ffmpeg-core.js`, 'text/javascript'),
      wasmURL: await toBlobURL(`${BASE_URL}/ffmpeg-core.wasm`, 'application/wasm'),
      workerURL: await toBlobURL(`${BASE_URL}/ffmpeg-core.worker.js`, 'text/javascript'),
    });

    this.loaded = true;
    console.log('FFmpeg.wasm yüklendi, multi-thread aktif');
  }

  async transcodeToH264(inputFile, outputOptions = {}) {
    const {
      crf = 23,
      preset = 'medium',
      audioCodec = 'aac',
      audioBitrate = '128k',
      resolution = null,
    } = outputOptions;

    const inputName = 'input_' + Date.now() + '.mp4';
    const outputName = 'output_' + Date.now() + '.mp4';

    // Dosyayı WASM sanal dosya sistemine yaz
    await this.ffmpeg.writeFile(inputName, await fetchFile(inputFile));

    const args = [
      '-i', inputName,
      '-c:v', 'libx264',
      '-crf', String(crf),
      '-preset', preset,
      '-c:a', audioCodec,
      '-b:a', audioBitrate,
    ];

    if (resolution) {
      args.push('-vf', `scale=${resolution}:-2`);
    }

    args.push('-movflags', '+faststart', outputName);

    await this.ffmpeg.exec(args);

    const data = await this.ffmpeg.readFile(outputName);

    // Temizlik
    await this.ffmpeg.deleteFile(inputName);
    await this.ffmpeg.deleteFile(outputName);

    return new Blob([data.buffer], { type: 'video/mp4' });
  }

  async extractThumbnail(videoFile, timestampSeconds = 1) {
    const inputName = 'thumb_input_' + Date.now() + '.mp4';
    const outputName = 'thumb_' + Date.now() + '.jpg';

    await this.ffmpeg.writeFile(inputName, await fetchFile(videoFile));

    await this.ffmpeg.exec([
      '-i', inputName,
      '-ss', String(timestampSeconds),
      '-vframes', '1',
      '-q:v', '2',
      outputName,
    ]);

    const data = await this.ffmpeg.readFile(outputName);
    await this.ffmpeg.deleteFile(inputName);
    await this.ffmpeg.deleteFile(outputName);

    return new Blob([data.buffer], { type: 'image/jpeg' });
  }
}
EOF

WebWorker ile Ana Thread’i Korumak

Codec işlemleri uzun sürer ve UI’yi bloklar. Web Worker kullanmak şart:

cat > src/codec.worker.js << 'EOF'
import { VideoTranscoder } from './video_transcoder.js';

let transcoder = null;

self.onmessage = async (event) => {
  const { type, id, payload } = event.data;

  try {
    switch (type) {
      case 'INIT':
        transcoder = new VideoTranscoder();
        await transcoder.load((progress, time) => {
          self.postMessage({ type: 'PROGRESS', id, progress, time });
        });
        self.postMessage({ type: 'READY', id });
        break;

      case 'TRANSCODE':
        if (!transcoder) throw new Error('Transcoder başlatılmamış');
        const blob = await transcoder.transcodeToH264(
          payload.file,
          payload.options
        );
        self.postMessage({ type: 'DONE', id, blob }, [blob]);
        break;

      case 'THUMBNAIL':
        const thumb = await transcoder.extractThumbnail(
          payload.file,
          payload.timestamp
        );
        self.postMessage({ type: 'THUMBNAIL_DONE', id, blob: thumb }, [thumb]);
        break;

      default:
        throw new Error('Bilinmeyen komut: ' + type);
    }
  } catch (err) {
    self.postMessage({ type: 'ERROR', id, error: err.message });
  }
};
EOF

Worker’ı ana thread’den yönetmek için sarmalayıcı sınıf:

cat > src/codec_manager.js << 'EOF'
export class CodecManager {
  constructor() {
    this.worker = new Worker(
      new URL('./codec.worker.js', import.meta.url),
      { type: 'module' }
    );
    this.pendingTasks = new Map();
    this.taskCounter = 0;

    this.worker.onmessage = (event) => this._handleMessage(event);
    this.worker.onerror = (err) => console.error('Worker hatası:', err);
  }

  _handleMessage({ data }) {
    const { type, id, ...rest } = data;
    const task = this.pendingTasks.get(id);
    if (!task) return;

    if (type === 'PROGRESS') {
      task.onProgress?.(rest.progress, rest.time);
    } else if (type === 'ERROR') {
      task.reject(new Error(rest.error));
      this.pendingTasks.delete(id);
    } else {
      task.resolve(rest);
      this.pendingTasks.delete(id);
    }
  }

  _sendTask(type, payload, onProgress) {
    return new Promise((resolve, reject) => {
      const id = ++this.taskCounter;
      this.pendingTasks.set(id, { resolve, reject, onProgress });
      this.worker.postMessage({ type, id, payload });
    });
  }

  async init() {
    await this._sendTask('INIT', {});
    console.log('Codec manager hazır');
  }

  async transcode(file, options = {}, onProgress = null) {
    const result = await this._sendTask(
      'TRANSCODE',
      { file, options },
      onProgress
    );
    return result.blob;
  }
}
EOF

Gerçek Dünya: Bulk Video Processing Pipeline

Bir içerik yönetim sistemi düşünelim. Kullanıcılar ham video yüklüyor, sistem otomatik olarak farklı çözünürlüklerde encode ediyor. Sunucu yükünü sıfıra indiren istemci taraflı pipeline:

cat > src/bulk_processor.js << 'EOF'
import { CodecManager } from './codec_manager.js';

const QUALITY_PRESETS = [
  { name: '1080p', resolution: '1920', crf: 23, audioBitrate: '192k' },
  { name: '720p',  resolution: '1280', crf: 24, audioBitrate: '128k' },
  { name: '480p',  resolution: '854',  crf: 26, audioBitrate: '96k'  },
  { name: '360p',  resolution: '640',  crf: 28, audioBitrate: '64k'  },
];

export async function processBulkVideos(files, onOverallProgress) {
  const manager = new CodecManager();
  await manager.init();

  const results = [];
  let completedJobs = 0;
  const totalJobs = files.length * QUALITY_PRESETS.length;

  for (const file of files) {
    const fileResults = { name: file.name, outputs: [] };

    for (const preset of QUALITY_PRESETS) {
      console.log(`İşleniyor: ${file.name} -> ${preset.name}`);

      const blob = await manager.transcode(
        file,
        {
          resolution: preset.resolution,
          crf: preset.crf,
          audioBitrate: preset.audioBitrate,
          preset: 'fast',
        },
        (progress) => {
          const overall = ((completedJobs / totalJobs) + 
            (progress / 100 / totalJobs)) * 100;
          onOverallProgress?.(Math.round(overall));
        }
      );

      fileResults.outputs.push({
        quality: preset.name,
        blob,
        size: blob.size,
        url: URL.createObjectURL(blob),
      });

      completedJobs++;
    }

    results.push(fileResults);
  }

  return results;
}
EOF

WASM Codec Performansını Ölçmek

Gerçek sayılar olmadan optimizasyon kör bıçak gibidir. Performance API ile ölçüm yapın:

cat > src/benchmark.js << 'EOF'
export async function benchmarkCodec(transcoder, testFile) {
  const results = {};

  // Warmup - WASM JIT derleme için
  console.log('Warmup turu...');
  await transcoder.extractThumbnail(testFile, 0);

  // H.264 encode benchmark
  const h264Start = performance.now();
  const h264Blob = await transcoder.transcodeToH264(testFile, {
    crf: 23,
    preset: 'fast',
    resolution: '720',
  });
  results.h264Time = performance.now() - h264Start;
  results.h264Size = h264Blob.size;

  // WebM/VP9 encode benchmark
  const vp9Start = performance.now();
  await transcoder.ffmpeg.exec([
    '-i', 'bench_input.mp4',
    '-c:v', 'libvpx-vp9',
    '-crf', '33',
    '-b:v', '0',
    '-c:a', 'libopus',
    'bench_output.webm',
  ]);
  results.vp9Time = performance.now() - vp9Start;

  const memInfo = performance.memory;
  if (memInfo) {
    results.heapUsed = Math.round(memInfo.usedJSHeapSize / 1024 / 1024);
    results.heapTotal = Math.round(memInfo.totalJSHeapSize / 1024 / 1024);
  }

  console.log('Benchmark sonuçları:', {
    'H.264 süre (ms)': Math.round(results.h264Time),
    'H.264 boyut (MB)': (results.h264Size / 1024 / 1024).toFixed(2),
    'VP9 süre (ms)': Math.round(results.vp9Time),
    'Heap kullanımı (MB)': results.heapUsed,
  });

  return results;
}
EOF

Nginx ile WASM Serving ve COOP/COEP Header Yapılandırması

Üretimde WASM dosyalarını doğru Content-Type ve güvenlik header’larıyla servis etmek gerekiyor:

# /etc/nginx/sites-available/wasm-codec.conf
cat > /etc/nginx/sites-available/wasm-codec.conf << 'EOF'
server {
    listen 443 ssl http2;
    server_name codec.example.com;

    ssl_certificate /etc/letsencrypt/live/codec.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/codec.example.com/privkey.pem;

    root /var/www/wasm-codec/dist;
    index index.html;

    # WASM dosyaları için doğru MIME type
    types {
        application/wasm wasm;
        application/javascript js mjs;
    }

    # COOP/COEP - SharedArrayBuffer için zorunlu
    add_header Cross-Origin-Opener-Policy "same-origin" always;
    add_header Cross-Origin-Embedder-Policy "require-corp" always;
    add_header Cross-Origin-Resource-Policy "same-site" always;

    # WASM binary'leri için agresif cache
    location ~* .wasm$ {
        add_header Content-Type "application/wasm";
        add_header Cache-Control "public, max-age=31536000, immutable";
        add_header Cross-Origin-Opener-Policy "same-origin" always;
        add_header Cross-Origin-Embedder-Policy "require-corp" always;
        gzip off;  # WASM zaten sıkıştırılmış
    }

    location ~* .(js|mjs)$ {
        add_header Cache-Control "public, max-age=31536000, immutable";
        add_header Cross-Origin-Opener-Policy "same-origin" always;
        add_header Cross-Origin-Embedder-Policy "require-corp" always;
        gzip on;
    }

    location / {
        try_files $uri $uri/ /index.html;
    }

    # Upload endpoint - sadece işlenmiş metadata alır, ham video değil
    location /api/register {
        proxy_pass http://localhost:3000;
        client_max_body_size 100M;
    }
}
EOF

nginx -t && systemctl reload nginx

Sık Karşılaşılan Sorunlar ve Çözümleri

Production deployment sürecinde mutlaka karşılaşacağınız bazı sorunlar var:

SharedArrayBuffer hatası: Cross-Origin-Opener-Policy ve Cross-Origin-Embedder-Policy header’ları eksikse multi-threaded FFmpeg.wasm çöker. Chrome DevTools Console’da SharedArrayBuffer is not defined hatası görürsünüz. Nginx konfigürasyonu yukarıdaki gibi ayarlayın.

WASM bellek taşması: Büyük dosyalarda RuntimeError: memory access out of bounds alırsanız INITIAL_MEMORY değerini artırın veya ALLOW_MEMORY_GROWTH=1 flag’ini ekleyin. 4K video için minimum 256MB başlangıç belleği öneririm.

Safari uyumluluğu: Safari’de WASM thread desteği iOS 16.4 sonrasına geliyor. Daha eski Safari için single-thread fallback build tutun:

# Single-thread build (Safari eski sürüm fallback)
emcc src/audio_codec.c 
  -O2 
  -o dist/audio_codec_st.js 
  -s WASM=1 
  -s SINGLE_FILE=1 
  -s EXPORTED_FUNCTIONS='["_encode_pcm_buffer","_malloc","_free"]' 
  -s ALLOW_MEMORY_GROWTH=1

# Feature detection
if (typeof SharedArrayBuffer === 'undefined') {
  // Single-thread build yükle
} else {
  // Multi-thread build yükle
}

Mobil cihazlarda OOM: Telefonda 4K encode denemeyin. Gelen dosyanın boyutunu kontrol edip kullanıcıyı uyarın, gerekirse çözünürlüğü otomatik düşürün. navigator.deviceMemory API ile cihaz belleğini sorgulamak mümkün, 2GB altında agresif compress ayarları kullanın.

Sonuç

WASM ile tarayıcıda codec çalıştırmak artık edge case değil, pratik bir production seçeneği. Sunucu maliyetlerini düşürmek, kullanıcı verilerini kendi cihazında işleyerek gizliliği artırmak ve ağ trafiğini azaltmak için somut kazanımlar sağlıyor.

En önemli operasyonel noktaları özetlemek gerekirse: COOP/COEP header’ları olmadan multi-thread çalışmaz, bu yüzden Nginx konfigürasyonuna dikkat edin. Web Worker kullanmak zorunlu, ana thread üzerinde codec çalıştırmak UI’yi donduruyor. Bellek yönetimi C kodunuzda da JavaScript tarafında da manuel yapılmak zorunda, malloc/free çiftlerini kaçırmak leak’e yol açıyor.

Eğer mevcut bir sunucu taraflı video işleme altyapınız varsa ve ölçekleme maliyetleri canınızı sıkıyorsa, FFmpeg.wasm ile basit bir proof of concept oluşturmak yarım günü bile almaz. Sayılar genellikle kendini savunuyor.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir