WebAssembly for Client-Side Image Processing
Hardi

Hardi @hardik_b2d8f0bca

About: Backend wizard by day, bug whisperer by night — coffee is my debugger.

Joined:
May 22, 2025

WebAssembly for Client-Side Image Processing

Publish Date: Jul 1
5 0

Client-side image processing has traditionally been limited by JavaScript's performance constraints. While JavaScript engines have improved dramatically, complex image operations like filtering, format conversion, and real-time manipulation still struggle with large images or demanding operations. WebAssembly (WASM) changes this paradigm completely.

WebAssembly enables near-native performance for image processing directly in the browser, opening possibilities that were previously only available on the server. This comprehensive guide explores how to leverage WASM for high-performance client-side image processing, from basic setup to advanced real-time applications.

Why WebAssembly for Image Processing?

The performance difference between JavaScript and WebAssembly for image processing is dramatic:

// Performance comparison: JavaScript vs WebAssembly
const performanceComparison = {
  javascript: {
    gaussianBlur_1920x1080: '2400ms',
    formatConversion_4K: '8500ms',
    colorSpaceTransform: '1200ms',
    edgeDetection: '3200ms',
    limitations: [
      'Single-threaded execution',
      'Garbage collection pauses',
      'Type coercion overhead',
      'Limited SIMD support'
    ]
  },
  webassembly: {
    gaussianBlur_1920x1080: '180ms',
    formatConversion_4K: '450ms',
    colorSpaceTransform: '85ms',
    edgeDetection: '240ms',
    advantages: [
      'Multi-threaded processing',
      'Predictable performance',
      'Direct memory access',
      'SIMD optimizations'
    ]
  },
  speedup: '8-15x faster for complex operations'
};
Enter fullscreen mode Exit fullscreen mode

Setting Up WebAssembly for Image Processing

Building with Emscripten

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

# Create project structure
mkdir wasm-image-processor
cd wasm-image-processor
mkdir src build js
Enter fullscreen mode Exit fullscreen mode

Core C Implementation

// src/image_processor.c - Core image processing functions
#include <emscripten.h>
#include <stdlib.h>
#include <math.h>
#include <string.h>

// Memory management
EMSCRIPTEN_KEEPALIVE
unsigned char* allocate_image_buffer(int size) {
    return malloc(size);
}

EMSCRIPTEN_KEEPALIVE
void free_image_buffer(unsigned char* buffer) {
    if (buffer) {
        free(buffer);
    }
}

// Basic grayscale filter
EMSCRIPTEN_KEEPALIVE
void grayscale_filter(unsigned char* data, int width, int height) {
    int total_pixels = width * height;

    for (int i = 0; i < total_pixels; i++) {
        int base = i * 4; // RGBA
        unsigned char r = data[base];
        unsigned char g = data[base + 1];
        unsigned char b = data[base + 2];

        // Luminance formula
        unsigned char gray = (unsigned char)(0.299 * r + 0.587 * g + 0.114 * b);

        data[base] = gray;
        data[base + 1] = gray;
        data[base + 2] = gray;
        // Alpha channel unchanged
    }
}

// Gaussian blur implementation
EMSCRIPTEN_KEEPALIVE
void gaussian_blur(unsigned char* input, unsigned char* output, 
                   int width, int height, float sigma) {
    int kernel_size = (int)(sigma * 3) * 2 + 1;
    float* kernel = malloc(kernel_size * sizeof(float));

    // Generate Gaussian kernel
    float sum = 0.0f;
    int half_size = kernel_size / 2;

    for (int i = 0; i < kernel_size; i++) {
        int x = i - half_size;
        kernel[i] = expf(-(x * x) / (2.0f * sigma * sigma));
        sum += kernel[i];
    }

    // Normalize kernel
    for (int i = 0; i < kernel_size; i++) {
        kernel[i] /= sum;
    }

    // Temporary buffer for horizontal pass
    unsigned char* temp = malloc(width * height * 4);

    // Horizontal blur pass
    for (int y = 0; y < height; y++) {
        for (int x = 0; x < width; x++) {
            float r = 0, g = 0, b = 0, a = 0;

            for (int k = 0; k < kernel_size; k++) {
                int src_x = x + k - half_size;
                if (src_x < 0) src_x = 0;
                if (src_x >= width) src_x = width - 1;

                int src_idx = (y * width + src_x) * 4;
                float weight = kernel[k];

                r += input[src_idx] * weight;
                g += input[src_idx + 1] * weight;
                b += input[src_idx + 2] * weight;
                a += input[src_idx + 3] * weight;
            }

            int dest_idx = (y * width + x) * 4;
            temp[dest_idx] = (unsigned char)r;
            temp[dest_idx + 1] = (unsigned char)g;
            temp[dest_idx + 2] = (unsigned char)b;
            temp[dest_idx + 3] = (unsigned char)a;
        }
    }

    // Vertical blur pass
    for (int y = 0; y < height; y++) {
        for (int x = 0; x < width; x++) {
            float r = 0, g = 0, b = 0, a = 0;

            for (int k = 0; k < kernel_size; k++) {
                int src_y = y + k - half_size;
                if (src_y < 0) src_y = 0;
                if (src_y >= height) src_y = height - 1;

                int src_idx = (src_y * width + x) * 4;
                float weight = kernel[k];

                r += temp[src_idx] * weight;
                g += temp[src_idx + 1] * weight;
                b += temp[src_idx + 2] * weight;
                a += temp[src_idx + 3] * weight;
            }

            int dest_idx = (y * width + x) * 4;
            output[dest_idx] = (unsigned char)r;
            output[dest_idx + 1] = (unsigned char)g;
            output[dest_idx + 2] = (unsigned char)b;
            output[dest_idx + 3] = (unsigned char)a;
        }
    }

    free(kernel);
    free(temp);
}

// Edge detection using Sobel operator
EMSCRIPTEN_KEEPALIVE
void sobel_edge_detection(unsigned char* input, unsigned char* output,
                          int width, int height) {
    // Sobel kernels
    int sobel_x[3][3] = {{-1, 0, 1}, {-2, 0, 2}, {-1, 0, 1}};
    int sobel_y[3][3] = {{-1, -2, -1}, {0, 0, 0}, {1, 2, 1}};

    for (int y = 1; y < height - 1; y++) {
        for (int x = 1; x < width - 1; x++) {
            int gx = 0, gy = 0;

            // Apply Sobel kernels
            for (int ky = -1; ky <= 1; ky++) {
                for (int kx = -1; kx <= 1; kx++) {
                    int pixel_idx = ((y + ky) * width + (x + kx)) * 4;
                    int gray = (input[pixel_idx] + input[pixel_idx + 1] + input[pixel_idx + 2]) / 3;

                    gx += gray * sobel_x[ky + 1][kx + 1];
                    gy += gray * sobel_y[ky + 1][kx + 1];
                }
            }

            // Calculate gradient magnitude
            int magnitude = (int)sqrtf(gx * gx + gy * gy);
            if (magnitude > 255) magnitude = 255;

            int output_idx = (y * width + x) * 4;
            output[output_idx] = magnitude;
            output[output_idx + 1] = magnitude;
            output[output_idx + 2] = magnitude;
            output[output_idx + 3] = 255; // Alpha
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Build Configuration

# Makefile for building WASM module
CC = emcc
CFLAGS = -O3 -s WASM=1 \
         -s EXPORTED_RUNTIME_METHODS='["cwrap","ccall"]' \
         -s ALLOW_MEMORY_GROWTH=1 \
         -s EXPORTED_FUNCTIONS='["_malloc","_free"]' \
         -s MODULARIZE=1 \
         -s EXPORT_NAME="ImageProcessor"

SRCDIR = src
BUILDDIR = build
SOURCES = $(SRCDIR)/image_processor.c

all: $(BUILDDIR)/image_processor.js

$(BUILDDIR)/image_processor.js: $(SOURCES)
    @mkdir -p $(BUILDDIR)
    $(CC) $(CFLAGS) $(SOURCES) -o $@

clean:
    rm -rf $(BUILDDIR)

.PHONY: all clean
Enter fullscreen mode Exit fullscreen mode

JavaScript Integration Layer

// js/wasm-image-processor.js - High-level JavaScript wrapper
class WASMImageProcessor {
  constructor() {
    this.wasmModule = null;
    this.isInitialized = false;
  }

  async initialize() {
    if (this.isInitialized) return;

    try {
      // Load the WASM module
      const ImageProcessor = await import('./build/image_processor.js');
      this.wasmModule = await ImageProcessor.default();

      // Setup function wrappers
      this.setupFunctionWrappers();

      this.isInitialized = true;
      console.log('WASM Image Processor initialized successfully');

    } catch (error) {
      console.error('Failed to initialize WASM module:', error);
      throw error;
    }
  }

  setupFunctionWrappers() {
    // Wrap C functions for easier JavaScript use
    this.allocateBuffer = this.wasmModule.cwrap('allocate_image_buffer', 'number', ['number']);
    this.freeBuffer = this.wasmModule.cwrap('free_image_buffer', null, ['number']);
    this.grayscaleFilter = this.wasmModule.cwrap('grayscale_filter', null, ['number', 'number', 'number']);
    this.gaussianBlur = this.wasmModule.cwrap('gaussian_blur', null, ['number', 'number', 'number', 'number', 'number']);
    this.sobelEdgeDetection = this.wasmModule.cwrap('sobel_edge_detection', null, ['number', 'number', 'number', 'number']);
  }

  async processImageData(imageData, operation, params = {}) {
    if (!this.isInitialized) {
      await this.initialize();
    }

    const { data, width, height } = imageData;
    const imageSize = width * height * 4; // RGBA

    // Allocate WASM memory
    const inputPtr = this.allocateBuffer(imageSize);
    const outputPtr = this.allocateBuffer(imageSize);

    try {
      // Copy image data to WASM memory
      this.wasmModule.HEAPU8.set(data, inputPtr);

      // Perform the operation
      const startTime = performance.now();
      await this.performOperation(inputPtr, outputPtr, width, height, operation, params);
      const endTime = performance.now();

      // Copy result back to JavaScript
      const resultData = new Uint8ClampedArray(
        this.wasmModule.HEAPU8.buffer,
        outputPtr,
        imageSize
      );

      // Create new ImageData object
      const resultImageData = new ImageData(
        new Uint8ClampedArray(resultData),
        width,
        height
      );

      return {
        imageData: resultImageData,
        processingTime: endTime - startTime,
        operation,
        params
      };

    } finally {
      // Clean up memory
      this.freeBuffer(inputPtr);
      this.freeBuffer(outputPtr);
    }
  }

  async performOperation(inputPtr, outputPtr, width, height, operation, params) {
    switch (operation) {
      case 'grayscale':
        this.grayscaleFilter(inputPtr, width, height);
        // Copy input to output for grayscale (in-place operation)
        this.wasmModule.HEAPU8.copyWithin(outputPtr, inputPtr, inputPtr + width * height * 4);
        break;

      case 'blur':
        const sigma = params.sigma || 2.0;
        this.gaussianBlur(inputPtr, outputPtr, width, height, sigma);
        break;

      case 'edge_detection':
        this.sobelEdgeDetection(inputPtr, outputPtr, width, height);
        break;

      default:
        throw new Error(`Unsupported operation: ${operation}`);
    }
  }

  // Memory management utilities
  getMemoryUsage() {
    if (!this.wasmModule) return null;

    return {
      heapSize: this.wasmModule.HEAPU8.length,
      usedMemory: this.wasmModule.HEAPU8.length
    };
  }

  // Performance benchmarking
  async benchmark(imageData, iterations = 10) {
    const operations = ['grayscale', 'blur', 'edge_detection'];
    const results = {};

    for (const operation of operations) {
      const times = [];

      for (let i = 0; i < iterations; i++) {
        const result = await this.processImageData(imageData, operation);
        times.push(result.processingTime);
      }

      results[operation] = {
        averageTime: times.reduce((sum, t) => sum + t, 0) / times.length,
        minTime: Math.min(...times),
        maxTime: Math.max(...times)
      };
    }

    return results;
  }
}

// Export for use in different module systems
if (typeof module !== 'undefined' && module.exports) {
  module.exports = WASMImageProcessor;
} else if (typeof define === 'function' && define.amd) {
  define([], () => WASMImageProcessor);
} else {
  window.WASMImageProcessor = WASMImageProcessor;
}
Enter fullscreen mode Exit fullscreen mode

Real-Time Processing Application

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Real-Time WASM Image Processing</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 0;
            padding: 20px;
            background: #f5f5f5;
        }

        .container {
            max-width: 1200px;
            margin: 0 auto;
            background: white;
            border-radius: 8px;
            padding: 20px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        }

        .canvas-container {
            display: flex;
            gap: 20px;
            margin: 20px 0;
            flex-wrap: wrap;
        }

        .canvas-wrapper {
            flex: 1;
            min-width: 300px;
        }

        .canvas-wrapper h3 {
            margin: 0 0 10px 0;
            color: #333;
        }

        canvas {
            border: 1px solid #ddd;
            border-radius: 4px;
            max-width: 100%;
            height: auto;
        }

        .controls {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
            gap: 20px;
            margin: 20px 0;
        }

        .control-group {
            background: #f8f9fa;
            padding: 15px;
            border-radius: 6px;
        }

        .control-group h4 {
            margin: 0 0 15px 0;
            color: #495057;
        }

        .slider-control {
            margin: 10px 0;
        }

        .slider-control label {
            display: block;
            margin-bottom: 5px;
            font-weight: 500;
        }

        .slider-control input[type="range"] {
            width: 100%;
            margin-bottom: 5px;
        }

        .slider-value {
            font-size: 12px;
            color: #6c757d;
        }

        .button-group {
            display: flex;
            gap: 10px;
            flex-wrap: wrap;
        }

        button {
            padding: 8px 16px;
            border: none;
            border-radius: 4px;
            background: #007bff;
            color: white;
            cursor: pointer;
            transition: background-color 0.2s;
        }

        button:hover {
            background: #0056b3;
        }

        button:disabled {
            background: #6c757d;
            cursor: not-allowed;
        }

        .performance-info {
            background: #e9ecef;
            padding: 10px;
            border-radius: 4px;
            font-family: monospace;
            font-size: 12px;
            margin: 10px 0;
            white-space: pre-line;
        }

        .status {
            padding: 10px;
            border-radius: 4px;
            margin: 10px 0;
        }

        .status.loading {
            background: #fff3cd;
            color: #856404;
        }

        .status.ready {
            background: #d4edda;
            color: #155724;
        }

        .status.error {
            background: #f8d7da;
            color: #721c24;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>Real-Time WebAssembly Image Processing</h1>

        <div class="status loading" id="status">
            Initializing WebAssembly module...
        </div>

        <div class="performance-info" id="performance">
            Performance metrics will appear here...
        </div>

        <div class="button-group">
            <input type="file" id="fileInput" accept="image/*">
            <button id="loadSample">Load Sample Image</button>
            <button id="benchmark">Run Benchmark</button>
            <button id="resetImage" disabled>Reset</button>
        </div>

        <div class="canvas-container">
            <div class="canvas-wrapper">
                <h3>Original</h3>
                <canvas id="originalCanvas"></canvas>
            </div>
            <div class="canvas-wrapper">
                <h3>Processed</h3>
                <canvas id="processedCanvas"></canvas>
            </div>
        </div>

        <div class="controls">
            <div class="control-group">
                <h4>Basic Filters</h4>
                <div class="button-group">
                    <button id="applyGrayscale">Grayscale</button>
                    <button id="applyEdgeDetection">Edge Detection</button>
                </div>
            </div>

            <div class="control-group">
                <h4>Blur</h4>
                <div class="slider-control">
                    <label for="blurSlider">Gaussian Blur</label>
                    <input type="range" id="blurSlider" min="0" max="10" step="0.1" value="0">
                    <div class="slider-value" id="blurValue">0.0</div>
                </div>
            </div>
        </div>
    </div>

    <script>
        class RealTimeImageProcessor {
            constructor() {
                this.processor = new WASMImageProcessor();
                this.originalImageData = null;
                this.currentImageData = null;
                this.isProcessing = false;

                this.originalCanvas = document.getElementById('originalCanvas');
                this.processedCanvas = document.getElementById('processedCanvas');
                this.originalCtx = this.originalCanvas.getContext('2d');
                this.processedCtx = this.processedCanvas.getContext('2d');

                this.setupEventListeners();
                this.initializeProcessor();
            }

            async initializeProcessor() {
                try {
                    await this.processor.initialize();
                    this.updateStatus('ready', 'WebAssembly module ready!');
                    this.enableControls(true);
                } catch (error) {
                    this.updateStatus('error', `Failed to initialize: ${error.message}`);
                }
            }

            setupEventListeners() {
                // File input
                document.getElementById('fileInput').addEventListener('change', 
                    (e) => this.handleFileLoad(e));

                // Basic operations
                document.getElementById('applyGrayscale').addEventListener('click', 
                    () => this.applyFilter('grayscale'));
                document.getElementById('applyEdgeDetection').addEventListener('click', 
                    () => this.applyFilter('edge_detection'));

                // Slider with real-time processing
                this.setupSlider('blurSlider', 'blurValue', (value) => 
                    this.applyFilter('blur', { sigma: parseFloat(value) }));

                // Utility buttons
                document.getElementById('loadSample').addEventListener('click', 
                    () => this.loadSampleImage());
                document.getElementById('benchmark').addEventListener('click', 
                    () => this.runBenchmark());
                document.getElementById('resetImage').addEventListener('click', 
                    () => this.resetToOriginal());
            }

            setupSlider(sliderId, valueId, callback) {
                const slider = document.getElementById(sliderId);
                const valueDisplay = document.getElementById(valueId);

                let debounceTimer;
                slider.addEventListener('input', (e) => {
                    valueDisplay.textContent = e.target.value;

                    // Debounce for real-time processing
                    clearTimeout(debounceTimer);
                    debounceTimer = setTimeout(() => callback(e.target.value), 150);
                });
            }

            async handleFileLoad(event) {
                const file = event.target.files[0];
                if (!file) return;

                const img = new Image();
                img.onload = () => {
                    this.loadImageToCanvas(img);
                    document.getElementById('resetImage').disabled = false;
                };
                img.src = URL.createObjectURL(file);
            }

            loadImageToCanvas(img) {
                // Set canvas size
                const maxSize = 800;
                let { width, height } = img;

                if (width > maxSize || height > maxSize) {
                    const ratio = Math.min(maxSize / width, maxSize / height);
                    width *= ratio;
                    height *= ratio;
                }

                this.originalCanvas.width = this.processedCanvas.width = width;
                this.originalCanvas.height = this.processedCanvas.height = height;

                // Draw original image
                this.originalCtx.drawImage(img, 0, 0, width, height);
                this.processedCtx.drawImage(img, 0, 0, width, height);

                // Store image data
                this.originalImageData = this.originalCtx.getImageData(0, 0, width, height);
                this.currentImageData = this.processedCtx.getImageData(0, 0, width, height);

                this.updatePerformanceInfo('Image loaded', {
                    dimensions: `${width}x${height}`,
                    pixels: width * height,
                    dataSize: `${(this.originalImageData.data.length / 1024).toFixed(1)}KB`
                });
            }

            async loadSampleImage() {
                // Create a sample gradient image for testing
                const width = 400;
                const height = 300;

                this.originalCanvas.width = this.processedCanvas.width = width;
                this.originalCanvas.height = this.processedCanvas.height = height;

                // Generate colorful test pattern
                const imageData = this.originalCtx.createImageData(width, height);
                const data = imageData.data;

                for (let y = 0; y < height; y++) {
                    for (let x = 0; x < width; x++) {
                        const index = (y * width + x) * 4;
                        data[index] = (x / width) * 255;     // Red gradient
                        data[index + 1] = (y / height) * 255; // Green gradient
                        data[index + 2] = ((x + y) / (width + height)) * 255; // Blue gradient
                        data[index + 3] = 255; // Alpha
                    }
                }

                this.originalCtx.putImageData(imageData, 0, 0);
                this.processedCtx.putImageData(imageData, 0, 0);

                this.originalImageData = imageData;
                this.currentImageData = this.processedCtx.getImageData(0, 0, width, height);

                document.getElementById('resetImage').disabled = false;
                this.updatePerformanceInfo('Sample image generated', {
                    dimensions: `${width}x${height}`,
                    type: 'Generated gradient pattern'
                });
            }

            async applyFilter(operation, params = {}) {
                if (!this.currentImageData || this.isProcessing) return;

                this.isProcessing = true;
                this.updateStatus('loading', `Applying ${operation}...`);

                try {
                    const startTime = performance.now();
                    const result = await this.processor.processImageData(
                        this.currentImageData, operation, params);
                    const endTime = performance.now();

                    // Update processed canvas
                    this.processedCtx.putImageData(result.imageData, 0, 0);
                    this.currentImageData = result.imageData;

                    this.updatePerformanceInfo(`${operation} applied`, {
                        processingTime: `${(endTime - startTime).toFixed(2)}ms`,
                        operation,
                        params: JSON.stringify(params)
                    });

                    this.updateStatus('ready', 'Processing complete');

                } catch (error) {
                    console.error('Processing failed:', error);
                    this.updateStatus('error', `Processing failed: ${error.message}`);
                } finally {
                    this.isProcessing = false;
                }
            }

            async runBenchmark() {
                if (!this.originalImageData) {
                    await this.loadSampleImage();
                }

                this.updateStatus('loading', 'Running performance benchmark...');

                try {
                    const results = await this.processor.benchmark(this.originalImageData, 5);

                    let benchmarkText = 'Benchmark Results (5 iterations):\n';
                    for (const [operation, stats] of Object.entries(results)) {
                        benchmarkText += `${operation}: avg ${stats.averageTime.toFixed(2)}ms, `;
                        benchmarkText += `min ${stats.minTime.toFixed(2)}ms, `;
                        benchmarkText += `max ${stats.maxTime.toFixed(2)}ms\n`;
                    }

                    this.updatePerformanceInfo('Benchmark completed', { results: benchmarkText });
                    this.updateStatus('ready', 'Benchmark complete');

                } catch (error) {
                    this.updateStatus('error', `Benchmark failed: ${error.message}`);
                }
            }

            resetToOriginal() {
                if (!this.originalImageData) return;

                this.processedCtx.putImageData(this.originalImageData, 0, 0);
                this.currentImageData = this.originalImageData;

                // Reset all sliders
                document.getElementById('blurSlider').value = 0;
                document.getElementById('blurValue').textContent = '0.0';

                this.updatePerformanceInfo('Image reset', { action: 'Restored to original' });
            }

            updateStatus(type, message) {
                const statusElement = document.getElementById('status');
                statusElement.className = `status ${type}`;
                statusElement.textContent = message;
            }

            updatePerformanceInfo(action, details = {}) {
                const perfElement = document.getElementById('performance');
                const timestamp = new Date().toLocaleTimeString();

                let info = `[${timestamp}] ${action}\n`;
                for (const [key, value] of Object.entries(details)) {
                    info += `  ${key}: ${value}\n`;
                }

                // Show memory usage if available
                const memInfo = this.processor.getMemoryUsage();
                if (memInfo) {
                    info += `  WASM Memory: ${(memInfo.heapSize / 1024 / 1024).toFixed(2)}MB\n`;
                }

                perfElement.textContent = info;
            }

            enableControls(enabled) {
                const buttons = document.querySelectorAll('button:not(#loadSample)');
                const sliders = document.querySelectorAll('input[type="range"]');

                buttons.forEach(btn => btn.disabled = !enabled);
                sliders.forEach(slider => slider.disabled = !enabled);
            }
        }

        // Load WASM processor and initialize application
        const script = document.createElement('script');
        script.src = './js/wasm-image-processor.js';
        script.onload = () => {
            window.addEventListener('DOMContentLoaded', () => {
                new RealTimeImageProcessor();
            });
        };
        document.head.appendChild(script);
    </script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Testing and Validation

When implementing WebAssembly for client-side image processing, thorough testing across different browsers and devices is essential to ensure consistent performance and compatibility. I often use tools like ConverterToolsKit during development to generate test images in various formats and sizes, helping validate that the WASM processing pipeline handles

Comments 0 total

    Add comment