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.
