Zum Hauptinhalt springen
development

Tools-Zentrale: Hybrid-Web-App Case Study

Technische Deep-Dive in unsere Tool-Plattform: Von der Hybrid-Architektur uber Docker-Microservices bis zur AI-Integration mit Whisper, rembg und Real-ESRGAN.

Onur CirakogluOnur Cirakoglu
14 Min. Lesezeit
#next-js#case-study#docker#microservices#typescript#ki
Server-Raum mit blauen Lichtern symbolisiert die Microservice-Architektur

99+ kostenlose Online-Tools, keine Installation, keine Registrierung. Das war die Vision fur die Tools-Zentrale. Die Herausforderung: Wie verarbeitet man PDFs, Videos, Audio und Bilder direkt im Browser – und nutzt gleichzeitig AI-Modelle wie Whisper oder Real-ESRGAN? Die Antwort: Eine Hybrid-Architektur mit 11 Docker-Microservices.

Das Problem: Komplexe Operationen ohne Software-Installation

Nutzeranforderungen

Unsere Zielgruppe wunscht sich:

  1. Keine Installation – Alles im Browser, sofort nutzbar
  2. Keine RegistrierungDatenschutz durch Anonymitat
  3. Keine Kosten – Kostenlose Nutzung ohne versteckte Gebuehren
  4. Professionelle Qualitat – Ergebnisse wie mit Desktop-Software

Die technische Realitat

Browser-basierte Verarbeitung hat Grenzen:

OperationBrowser-FahigGrund
PDF zusammenfuegenJapdf-lib funktioniert client-side
PDF zu WordNeinLibreOffice benoetigt Server
Video komprimierenBegrenztFFmpeg WASM ist langsam (5-10x)
AI TranskriptionNeinWhisper-Modelle zu gross (1-3GB)
Hintergrund entfernenNeinAI-Modelle benoetigen GPU/viel RAM
Bild-UpscalingNeinReal-ESRGAN zu ressourcenintensiv

Erkenntnis: Wir brauchen eine Hybrid-Losung – client-side wo moeglich, server-side wo noetig.

Architektur-Entscheidungen

Warum Hybrid-Processing?

Die Entscheidung fiel auf eine zweistufige Architektur:

Nutzer-Browser
     |
     v
+--------------------+
| Next.js Frontend   |
| (React 19 + WASM)  |
+--------------------+
     |
     +---> Client-side: pdf-lib, FFmpeg WASM, Tesseract.js
     |
     +---> Server-side: Docker Microservices
                |
                v
        +---------------+
        | Redis/BullMQ  |  <-- Job Queue
        +---------------+
                |
    +-----------+-----------+-----------+
    |           |           |           |
    v           v           v           v
+-------+  +--------+  +-------+  +-----+
|Whisper|  | rembg  |  |ESRGAN |  | ... |
+-------+  +--------+  +-------+  +-----+

Vorteile:

  • Schnelle Operationen (PDF merge, Text-Tools) laufen instant im Browser
  • Rechenintensive AI-Operationen auf dedizierten Servern
  • Automatisches Fallback bei Service-Ausfall
  • Skalierbar: Services unabhaengig skalierbar

Warum Docker Microservices?

Jeder Service hat spezifische Anforderungen:

ServiceBase ImageGrund
pdf-converterNode + LibreOfficeLibreOffice benoetigt vollstaendige Installation
whisperPython + PyTorchGPU-optimierte AI-Inferenz
rembgPython + ONNXSpezialisiertes AI-Modell
esrganPython + PyTorchGPU-beschleunigtes Upscaling
ffmpegNode + FFmpegNative Video-Encoding
tesseractPython + TesseractOCR mit trainierten Modellen
ttsPython + Coqui TTSText-to-Speech Synthese
libretranslatePython + LibreTranslateNeurale Ubersetzung

Isolation: Jeder Service hat eigene Dependencies – keine Konflikte zwischen Python-Versionen oder Library-Inkompatibilitaeten.

Die Service-Landschaft

# docker-compose.yml (vereinfacht)
services:
  pdf-converter:
    build: ./docker/pdf-converter
    ports: ["3099:3099"]
    mem_limit: 1g
    depends_on: [redis]
 
  whisper:
    build: ./docker/whisper
    ports: ["3100:3100"]
    mem_limit: 2g
    # GPU-Support wenn verfuegbar
    deploy:
      resources:
        reservations:
          devices:
            - capabilities: [gpu]
 
  rembg:
    build: ./docker/rembg
    ports: ["3101:3101"]
    mem_limit: 1g
 
  esrgan:
    build: ./docker/esrgan
    ports: ["3102:3102"]
    mem_limit: 1g
 
  # ... weitere Services
 
  redis:
    image: redis:alpine
    ports: ["6379:6379"]
    mem_limit: 256m

Job-Queue mit Redis und BullMQ

Das Problem der langen Verarbeitung

AI-Operationen dauern 5-60 Sekunden. HTTP-Timeouts und User-Experience werden zum Problem.

Die Loesung: Asynchrone Job-Queue

// lib/api/server-processing.ts (vereinfacht)
export async function submitPdfJob(
  file: File,
  operation: 'convert' | 'compress' | 'ocr'
): Promise<string> {
  const formData = new FormData();
  formData.append('file', file);
  formData.append('operation', operation);
 
  const response = await fetch('/api/pdf/jobs', {
    method: 'POST',
    body: formData,
  });
 
  const { jobId } = await response.json();
  return jobId; // Sofortige Antwort
}
 
export async function getJobStatus(jobId: string): Promise<JobStatus> {
  const response = await fetch(`/api/pdf/jobs/${jobId}/status`);
  return response.json();
}

Flow:

  1. Nutzer laedt Datei hoch
  2. Server erstellt Job in Redis-Queue
  3. Worker nimmt Job entgegen
  4. Frontend pollt Status alle 1-2 Sekunden
  5. Bei Fertigstellung: Download-Link bereit

Custom Hooks fuer Developer Experience

useQueueJob: Polling und Status-Tracking

// hooks/useQueueJob.ts
export function useQueueJob(options: UseQueueJobOptions = {}): UseQueueJobReturn {
  const { pollInterval = 1000, autoDownload = true, onComplete, onError } = options;
 
  const [jobId, setJobId] = useState<string | null>(null);
  const [status, setStatus] = useState<JobStatus | null>(null);
  const [progress, setProgress] = useState(0);
  const [position, setPosition] = useState(0);
  const [estimatedTime, setEstimatedTime] = useState<number | null>(null);
 
  // Polling-Logik
  useEffect(() => {
    if (!jobId || !isPolling) return;
 
    const poll = async () => {
      const jobStatus = await getJobStatus(jobId);
 
      setStatus(jobStatus.status);
      setProgress(jobStatus.progress);
      setPosition(jobStatus.position);
 
      if (jobStatus.status === 'completed') {
        setIsPolling(false);
 
        if (autoDownload) {
          const result = await downloadJobResult(jobId);
          // Browser-Download triggern
          triggerDownload(result.blob, result.filename);
          onComplete?.(result.blob, result.filename);
        }
        return;
      }
 
      if (jobStatus.status === 'failed') {
        setIsPolling(false);
        onError?.(new Error(jobStatus.error));
        return;
      }
 
      // Weiter pollen
      pollingRef.current = setTimeout(poll, pollInterval);
    };
 
    poll();
  }, [jobId, isPolling]);
 
  return {
    status,
    progress,
    position,
    estimatedTime,
    startJob: (id: string) => { setJobId(id); setIsPolling(true); },
    cancel: () => cancelJob(jobId),
    reset: () => { /* Reset state */ },
  };
}

Verwendung in Komponenten:

function PdfToWordTool() {
  const { status, progress, position, startJob } = useQueueJob({
    onComplete: (blob, filename) => {
      toast.success(`${filename} erfolgreich konvertiert!`);
    },
    onError: (error) => {
      toast.error(error.message);
    },
  });
 
  const handleUpload = async (file: File) => {
    const jobId = await submitPdfJob(file, 'convert');
    startJob(jobId);
  };
 
  return (
    <div>
      {status === 'waiting' && (
        <p>Position in Warteschlange: {position}</p>
      )}
      {status === 'active' && (
        <ProgressBar value={progress} />
      )}
    </div>
  );
}

useBatchJob: Mehrere Dateien parallel

// hooks/useBatchJob.ts
export function useBatchJob(options: UseBatchJobOptions = {}): UseBatchJobReturn {
  const [totalFiles, setTotalFiles] = useState(0);
  const [completedFiles, setCompletedFiles] = useState(0);
  const [failedFiles, setFailedFiles] = useState(0);
  const [files, setFiles] = useState<BatchFileInfo[]>([]);
 
  // Batch-Status enthalt Details pro Datei
  useEffect(() => {
    if (!batchId || !isPolling) return;
 
    const poll = async () => {
      const batchStatus = await getBatchStatus(batchId);
 
      setTotalFiles(batchStatus.totalFiles);
      setCompletedFiles(batchStatus.completedFiles);
      setFailedFiles(batchStatus.failedFiles);
      setFiles(batchStatus.files); // Status jeder einzelnen Datei
 
      if (batchStatus.status === 'completed') {
        // ZIP-Download mit allen Ergebnissen
        const result = await downloadBatchResult(batchId);
        triggerDownload(result.blob, 'converted-files.zip');
      }
    };
 
    poll();
  }, [batchId, isPolling]);
 
  return {
    progress: Math.round((completedFiles / totalFiles) * 100),
    totalFiles,
    completedFiles,
    failedFiles,
    files, // Fuer detaillierte Fortschrittsanzeige
    startBatch,
    cancel,
  };
}

useFFmpeg: Client-side Video-Processing

// hooks/useFFmpeg.ts
export function useFFmpeg(): UseFFmpegReturn {
  const [status, setStatus] = useState<FFmpegStatus>('unloaded');
  const [progress, setProgress] = useState(0);
  const ffmpegRef = useRef<FFmpeg | null>(null);
 
  const load = useCallback(async () => {
    setStatus('loading');
 
    const { FFmpeg } = await import('@ffmpeg/ffmpeg');
    const { toBlobURL } = await import('@ffmpeg/util');
 
    const ffmpeg = new FFmpeg();
 
    // Progress-Events
    ffmpeg.on('progress', ({ progress: p }) => {
      setProgress(Math.round(p * 100));
    });
 
    // WASM-Core von CDN laden (nur einmal)
    const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.15/dist/esm';
    await ffmpeg.load({
      coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
      wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
    });
 
    ffmpegRef.current = ffmpeg;
    setStatus('ready');
  }, []);
 
  const process = useCallback(async (
    inputFiles: { name: string; data: Uint8Array }[],
    command: string[],
    outputFilename: string
  ): Promise<Uint8Array | null> => {
    const ffmpeg = ffmpegRef.current!;
 
    // Dateien ins WASM-Filesystem schreiben
    for (const file of inputFiles) {
      await ffmpeg.writeFile(file.name, file.data);
    }
 
    // FFmpeg-Befehl ausfuehren
    await ffmpeg.exec(command);
 
    // Ergebnis lesen
    const data = await ffmpeg.readFile(outputFilename);
 
    // Cleanup: Temporaere Dateien loeschen
    for (const file of inputFiles) {
      await ffmpeg.deleteFile(file.name);
    }
 
    return data as Uint8Array;
  }, []);
 
  return { status, progress, load, process, cancel };
}

Beispiel: Video zu GIF

function VideoToGifTool() {
  const { status, progress, load, process } = useFFmpeg();
 
  const convertToGif = async (videoFile: File) => {
    // FFmpeg WASM laden (lazy loading)
    if (status === 'unloaded') await load();
 
    const videoData = new Uint8Array(await videoFile.arrayBuffer());
 
    // FFmpeg-Befehl: Video zu GIF
    const gifData = await process(
      [{ name: 'input.mp4', data: videoData }],
      [
        '-i', 'input.mp4',
        '-vf', 'fps=10,scale=480:-1:flags=lanczos',
        '-loop', '0',
        'output.gif'
      ],
      'output.gif'
    );
 
    // Download triggern
    const blob = new Blob([gifData!], { type: 'image/gif' });
    downloadBlob(blob, 'converted.gif');
  };
 
  return (
    <div>
      {status === 'loading' && <p>FFmpeg wird geladen...</p>}
      {status === 'processing' && <ProgressBar value={progress} />}
    </div>
  );
}

AI-Integration: Die Service-Clients

Whisper: Audio-Transkription

// lib/api/ai-services.ts
export async function transcribeAudio(
  file: File,
  options?: {
    language?: string;
    task?: 'transcribe' | 'translate';
  }
): Promise<TranscriptionResult> {
  const formData = new FormData();
  formData.append('file', file);
  if (options?.language) formData.append('language', options.language);
  if (options?.task) formData.append('task', options.task);
 
  const response = await fetch('/api/ai/transcribe', {
    method: 'POST',
    body: formData,
  });
 
  return response.json();
}
 
// Rueckgabe
interface TranscriptionResult {
  text: string;
  language: string;
  segments?: Array<{
    start: number;
    end: number;
    text: string;
  }>;
}

Docker-Service (vereinfacht):

# docker/whisper/Dockerfile
FROM python:3.11-slim
 
# System-Dependencies fuer Audio
RUN apt-get update && apt-get install -y ffmpeg
 
# Whisper installieren
RUN pip install openai-whisper torch
 
# Modell beim Build laden (1.5GB)
RUN python -c "import whisper; whisper.load_model('base')"
 
COPY app.py .
 
CMD ["python", "app.py"]

rembg: Hintergrund-Entfernung

export async function removeBackground(
  file: File,
  options?: {
    alphaMatting?: boolean;
    onlyMask?: boolean;
    bgcolor?: string;
  }
): Promise<Blob> {
  const formData = new FormData();
  formData.append('file', file);
  if (options?.alphaMatting) formData.append('alpha_matting', 'true');
  if (options?.bgcolor) formData.append('bgcolor', options.bgcolor);
 
  const response = await fetch('/api/ai/remove-background', {
    method: 'POST',
    body: formData,
  });
 
  return response.blob();
}

Was rembg macht:

  • U2-Net AI-Modell fuer Segmentierung
  • Alpha Matting fuer feine Kanten (Haare)
  • Optional: Neuen Hintergrund einfuegen

Real-ESRGAN: Bild-Upscaling

export async function upscaleImage(
  file: File,
  options?: {
    scale?: 2 | 3 | 4;
    model?: 'general' | 'anime';
  }
): Promise<UpscaleResult> {
  const formData = new FormData();
  formData.append('file', file);
  if (options?.scale) formData.append('scale', options.scale.toString());
  if (options?.model) formData.append('model', options.model);
 
  const response = await fetch('/api/ai/upscale', {
    method: 'POST',
    body: formData,
  });
 
  return {
    blob: await response.blob(),
    originalSize: response.headers.get('X-Original-Size'),
    outputSize: response.headers.get('X-Output-Size'),
    processingTime: response.headers.get('X-Processing-Time'),
  };
}

Error Handling und Fallbacks

Service-Health-Checks

// Vor jeder Anfrage pruefen ob Service verfuegbar
export async function getTranscribeHealth(): Promise<HealthStatus> {
  try {
    const response = await fetch('/api/ai/transcribe');
    return response.json();
  } catch {
    return { status: 'unavailable', error: 'Service nicht erreichbar' };
  }
}
 
// In der Komponente
function AudioToTextTool() {
  const [serviceAvailable, setServiceAvailable] = useState<boolean | null>(null);
 
  useEffect(() => {
    getTranscribeHealth().then((health) => {
      setServiceAvailable(health.status === 'ok');
    });
  }, []);
 
  if (serviceAvailable === false) {
    return (
      <Alert variant="warning">
        Transkriptions-Service derzeit nicht verfuegbar.
        Bitte spaeter erneut versuchen.
      </Alert>
    );
  }
}

Client-side Fallbacks

// Beispiel: Hintergrund-Entfernung
async function removeBackgroundWithFallback(file: File): Promise<Blob> {
  // Zuerst Server-Service versuchen
  const health = await getRemoveBackgroundHealth();
 
  if (health.status === 'ok') {
    try {
      return await removeBackground(file);
    } catch (error) {
      console.warn('Server-Service fehlgeschlagen, fallback auf Client');
    }
  }
 
  // Fallback: @imgly/background-removal (client-side, langsamer)
  const { removeBackground: clientRemove } = await import('@imgly/background-removal');
  const blob = await clientRemove(file, {
    model: 'small',
    output: { format: 'image/png' },
  });
 
  return blob;
}

Memory Management fuer AI-Services

Das Problem

AI-Modelle sind speicherhungrig:

  • Whisper base: 1.5GB VRAM/RAM
  • Real-ESRGAN: 500MB-2GB je nach Bildgroesse
  • rembg (U2-Net): 300MB

Die Loesung: Resource Limits

# docker-compose.yml
services:
  whisper:
    mem_limit: 2g
    memswap_limit: 2g
    deploy:
      resources:
        limits:
          memory: 2G
        reservations:
          memory: 1G
 
  esrgan:
    mem_limit: 1g
    # Bildgroesse serverseitig limitieren
    environment:
      - MAX_IMAGE_SIZE=4096
      - MAX_FILE_SIZE_MB=20

Server-seitige Validierung:

# docker/esrgan/app.py
MAX_IMAGE_SIZE = int(os.getenv('MAX_IMAGE_SIZE', 4096))
MAX_FILE_SIZE_MB = int(os.getenv('MAX_FILE_SIZE_MB', 20))
 
@app.post('/upscale')
async def upscale(file: UploadFile):
    # Dateigroesse pruefen
    file_size = len(await file.read())
    if file_size > MAX_FILE_SIZE_MB * 1024 * 1024:
        raise HTTPException(400, f'Datei zu gross (max {MAX_FILE_SIZE_MB}MB)')
 
    # Bildgroesse pruefen
    image = Image.open(file.file)
    if max(image.size) > MAX_IMAGE_SIZE:
        raise HTTPException(400, f'Bild zu gross (max {MAX_IMAGE_SIZE}px)')
 
    # Verarbeitung...

Performance-Optimierung

WASM Lazy Loading

FFmpeg WASM ist 25MB gross – nicht fuer jeden Nutzer laden:

// Nur laden wenn Tool verwendet wird
const VideoTool = dynamic(() => import('./VideoTool'), {
  loading: () => <ToolSkeleton />,
  ssr: false, // WASM funktioniert nicht server-side
});
 
// Im useFFmpeg Hook: Lazy load der WASM-Module
const load = useCallback(async () => {
  // Dynamischer Import – nur wenn benoetigt
  const { FFmpeg } = await import('@ffmpeg/ffmpeg');
  const { toBlobURL } = await import('@ffmpeg/util');
 
  // Core von CDN laden
  const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.15/dist/esm';
  // ...
}, []);

Service Worker Caching

// app/manifest.ts
export default function manifest(): MetadataRoute.Manifest {
  return {
    name: 'Tools-Zentrale',
    short_name: 'Tools',
    start_url: '/',
    display: 'standalone',
    background_color: '#ffffff',
    theme_color: '#3b82f6',
    icons: [/* ... */],
  };
}
 
// next.config.ts
const withPWA = require('next-pwa')({
  dest: 'public',
  disable: process.env.NODE_ENV === 'development',
  runtimeCaching: [
    {
      // WASM-Dateien cachen
      urlPattern: /\.wasm$/,
      handler: 'CacheFirst',
      options: {
        cacheName: 'wasm-cache',
        expiration: {
          maxEntries: 10,
          maxAgeSeconds: 30 * 24 * 60 * 60, // 30 Tage
        },
      },
    },
  ],
});

CLS Prevention

Layout-Shifts vermeiden bei Tool-Loading:

/* globals.css */
.tool-dropzone {
  min-height: 200px;
}
 
.tool-dropzone-lg {
  min-height: 280px;
}
 
.tool-result-container {
  min-height: 120px;
}
 
.tool-main-area {
  min-height: 320px;
}

Lessons Learned

1. Hybrid ist der Weg

Weder reines Client-side noch reines Server-side ist optimal:

  • Client-side: Schnell, privat, aber limitiert
  • Server-side: Maechtig, aber teuer und langsamer
  • Hybrid: Das Beste aus beiden Welten

2. Job-Queues sind essentiell

Ohne BullMQ haetten wir:

  • HTTP-Timeouts bei langen Operationen
  • Keine Warteschlangen-Logik
  • Keine Retry-Moeglichkeit bei Fehlern
  • Schlechte UX (kein Fortschritt sichtbar)

3. Docker Isolation zahlt sich aus

Anfangs uebertrieben? Heute unverzichtbar:

  • Whisper braucht spezifische PyTorch-Version
  • LibreOffice hat komplexe System-Dependencies
  • Updates einzelner Services ohne Ausfallzeit

4. Memory Management nicht unterschaetzen

AI-Modelle sind hungrig:

  • Immer Limits setzen
  • Bildgroessen validieren
  • Health-Checks implementieren
  • Monitoring einrichten

5. Graceful Degradation

Wenn ein Service ausfallt:

  • Nutzer informieren
  • Fallback anbieten (wenn moeglich)
  • Nicht die ganze App blockieren

Ergebnisse

MetrikZielErreicht
Tools online50+99+
Durchschnittliche Verarbeitung< 10s3-8s
LCP< 2.5s1.4s
CLS< 0.10.03
Service Uptime99%99.7%
Memory Peaks< 2GB< 1.8GB

Tech-Stack Zusammenfassung

KomponenteTechnologieZweck
FrontendNext.js 16, React 19App-Framework
StylingTailwind CSS 4Responsive Design
StateZustandClient-State Management
PDF Clientpdf-lib, pdfjs-distClient-side PDF
Video ClientFFmpeg WASMClient-side Video
Job QueueRedis, BullMQAsync Processing
PDF ServerLibreOffice, qpdfServer-side Konvertierung
AI TranscriptionOpenAI WhisperAudio zu Text
AI Backgroundrembg (U2-Net)Hintergrund-Entfernung
AI UpscalingReal-ESRGANBild-Vergroesserung
AI TTSCoqui TTSText zu Sprache
TranslationLibreTranslateNeurale Ubersetzung
ContainerDocker ComposeService-Orchestrierung

Naechste Schritte

Die Tools-Zentrale waechst weiter:

  1. Mehr AI-Tools: Gesichtserkennung, Style Transfer
  2. GPU-Beschleunigung: NVIDIA Container Toolkit
  3. API-Zugang: REST-API fuer Entwickler
  4. Premium-Tier: Hoehere Limits, Priority-Queue

Sie planen ein komplexes Web-Projekt? Die Tools-Zentrale zeigt, wie wir skalierbare Hybrid-Architekturen mit AI-Integration umsetzen. Kontaktieren Sie uns fuer ein technisches Beratungsgespraech zu Ihrem Vorhaben.

Verifizierter Autor
Onur Cirakoglu - Profilbild

Onur Cirakoglu

Full-Stack Developer & Gründer

Lauda-Königshofen, Baden-Württemberg

Onur Cirakoglu ist Gründer und leitender Entwickler von HEADON.pro. Mit über 8 Jahren Erfahrung in der Webentwicklung spezialisiert er sich auf performante Next.js-Anwendungen, React Native Mobile Apps und komplexe Full-Stack-Lösungen. Seine Expertise umfasst moderne JavaScript-Frameworks, Cloud-Architekturen und SEO-optimierte Webanwendungen. Er berät Unternehmen im Main-Tauber-Kreis und darüber hinaus bei ihrer digitalen Transformation.

Bachelor of Science in Wirtschaftsinformatik - Hochschule Heilbronn (2016)

Expertise

Next.js & ReactTypeScriptReact NativeNode.jsSupabase & PostgreSQLPerformance OptimizationSEO & Core Web VitalsCloud Architecture
8+ Jahre praktische EntwicklungserfahrungGründer von HEADON.pro
8+ Jahre Erfahrung

Artikel teilen

Themen in diesem Artikel:

#next-js#case-study#docker#microservices#typescript#ki

Das könnte Sie auch interessieren

Weitere Artikel zu ähnlichen Themen