about summary refs log tree commit diff stats
path: root/js/leibovitz
diff options
context:
space:
mode:
Diffstat (limited to 'js/leibovitz')
-rw-r--r--js/leibovitz/ChicagoFLF.ttfbin0 -> 31256 bytes
-rw-r--r--js/leibovitz/android-icon-144x144.pngbin0 -> 3068 bytes
-rw-r--r--js/leibovitz/android-icon-192x192.pngbin0 -> 3253 bytes
-rw-r--r--js/leibovitz/android-icon-36x36.pngbin0 -> 1791 bytes
-rw-r--r--js/leibovitz/android-icon-48x48.pngbin0 -> 1953 bytes
-rw-r--r--js/leibovitz/android-icon-72x72.pngbin0 -> 2429 bytes
-rw-r--r--js/leibovitz/android-icon-96x96.pngbin0 -> 1993 bytes
-rw-r--r--js/leibovitz/apple-icon-114x114.pngbin0 -> 3027 bytes
-rw-r--r--js/leibovitz/apple-icon-120x120.pngbin0 -> 2916 bytes
-rw-r--r--js/leibovitz/apple-icon-144x144.pngbin0 -> 3068 bytes
-rw-r--r--js/leibovitz/apple-icon-152x152.pngbin0 -> 3882 bytes
-rw-r--r--js/leibovitz/apple-icon-180x180.pngbin0 -> 4656 bytes
-rw-r--r--js/leibovitz/apple-icon-57x57.pngbin0 -> 2258 bytes
-rw-r--r--js/leibovitz/apple-icon-60x60.pngbin0 -> 2291 bytes
-rw-r--r--js/leibovitz/apple-icon-72x72.pngbin0 -> 2429 bytes
-rw-r--r--js/leibovitz/apple-icon-76x76.pngbin0 -> 2572 bytes
-rw-r--r--js/leibovitz/apple-icon-precomposed.pngbin0 -> 3689 bytes
-rw-r--r--js/leibovitz/apple-icon.pngbin0 -> 3689 bytes
-rw-r--r--js/leibovitz/balance.js153
-rw-r--r--js/leibovitz/blur.js301
-rw-r--r--js/leibovitz/browserconfig.xml2
-rw-r--r--js/leibovitz/color.js322
-rw-r--r--js/leibovitz/contrast.js134
-rw-r--r--js/leibovitz/dither.js969
-rw-r--r--js/leibovitz/favicon-16x16.pngbin0 -> 1228 bytes
-rw-r--r--js/leibovitz/favicon-32x32.pngbin0 -> 1813 bytes
-rw-r--r--js/leibovitz/favicon-96x96.pngbin0 -> 1993 bytes
-rw-r--r--js/leibovitz/favicon.icobin0 -> 1150 bytes
-rw-r--r--js/leibovitz/index.html710
-rw-r--r--js/leibovitz/leibovitz.js955
-rw-r--r--js/leibovitz/manifest.json71
-rw-r--r--js/leibovitz/ms-icon-144x144.pngbin0 -> 3068 bytes
-rw-r--r--js/leibovitz/ms-icon-150x150.pngbin0 -> 3884 bytes
-rw-r--r--js/leibovitz/ms-icon-310x310.pngbin0 -> 9141 bytes
-rw-r--r--js/leibovitz/ms-icon-70x70.pngbin0 -> 2518 bytes
-rw-r--r--js/leibovitz/service-worker.js89
36 files changed, 3706 insertions, 0 deletions
diff --git a/js/leibovitz/ChicagoFLF.ttf b/js/leibovitz/ChicagoFLF.ttf
new file mode 100644
index 0000000..60691e1
--- /dev/null
+++ b/js/leibovitz/ChicagoFLF.ttf
Binary files differdiff --git a/js/leibovitz/android-icon-144x144.png b/js/leibovitz/android-icon-144x144.png
new file mode 100644
index 0000000..ae37a7e
--- /dev/null
+++ b/js/leibovitz/android-icon-144x144.png
Binary files differdiff --git a/js/leibovitz/android-icon-192x192.png b/js/leibovitz/android-icon-192x192.png
new file mode 100644
index 0000000..4fd03e4
--- /dev/null
+++ b/js/leibovitz/android-icon-192x192.png
Binary files differdiff --git a/js/leibovitz/android-icon-36x36.png b/js/leibovitz/android-icon-36x36.png
new file mode 100644
index 0000000..d5fbc51
--- /dev/null
+++ b/js/leibovitz/android-icon-36x36.png
Binary files differdiff --git a/js/leibovitz/android-icon-48x48.png b/js/leibovitz/android-icon-48x48.png
new file mode 100644
index 0000000..ae93a97
--- /dev/null
+++ b/js/leibovitz/android-icon-48x48.png
Binary files differdiff --git a/js/leibovitz/android-icon-72x72.png b/js/leibovitz/android-icon-72x72.png
new file mode 100644
index 0000000..89d3e98
--- /dev/null
+++ b/js/leibovitz/android-icon-72x72.png
Binary files differdiff --git a/js/leibovitz/android-icon-96x96.png b/js/leibovitz/android-icon-96x96.png
new file mode 100644
index 0000000..a4fcd87
--- /dev/null
+++ b/js/leibovitz/android-icon-96x96.png
Binary files differdiff --git a/js/leibovitz/apple-icon-114x114.png b/js/leibovitz/apple-icon-114x114.png
new file mode 100644
index 0000000..2a2af04
--- /dev/null
+++ b/js/leibovitz/apple-icon-114x114.png
Binary files differdiff --git a/js/leibovitz/apple-icon-120x120.png b/js/leibovitz/apple-icon-120x120.png
new file mode 100644
index 0000000..dd9823f
--- /dev/null
+++ b/js/leibovitz/apple-icon-120x120.png
Binary files differdiff --git a/js/leibovitz/apple-icon-144x144.png b/js/leibovitz/apple-icon-144x144.png
new file mode 100644
index 0000000..ae37a7e
--- /dev/null
+++ b/js/leibovitz/apple-icon-144x144.png
Binary files differdiff --git a/js/leibovitz/apple-icon-152x152.png b/js/leibovitz/apple-icon-152x152.png
new file mode 100644
index 0000000..c43bf96
--- /dev/null
+++ b/js/leibovitz/apple-icon-152x152.png
Binary files differdiff --git a/js/leibovitz/apple-icon-180x180.png b/js/leibovitz/apple-icon-180x180.png
new file mode 100644
index 0000000..f7435e7
--- /dev/null
+++ b/js/leibovitz/apple-icon-180x180.png
Binary files differdiff --git a/js/leibovitz/apple-icon-57x57.png b/js/leibovitz/apple-icon-57x57.png
new file mode 100644
index 0000000..7f5dfa5
--- /dev/null
+++ b/js/leibovitz/apple-icon-57x57.png
Binary files differdiff --git a/js/leibovitz/apple-icon-60x60.png b/js/leibovitz/apple-icon-60x60.png
new file mode 100644
index 0000000..3a6a826
--- /dev/null
+++ b/js/leibovitz/apple-icon-60x60.png
Binary files differdiff --git a/js/leibovitz/apple-icon-72x72.png b/js/leibovitz/apple-icon-72x72.png
new file mode 100644
index 0000000..89d3e98
--- /dev/null
+++ b/js/leibovitz/apple-icon-72x72.png
Binary files differdiff --git a/js/leibovitz/apple-icon-76x76.png b/js/leibovitz/apple-icon-76x76.png
new file mode 100644
index 0000000..9dc77b1
--- /dev/null
+++ b/js/leibovitz/apple-icon-76x76.png
Binary files differdiff --git a/js/leibovitz/apple-icon-precomposed.png b/js/leibovitz/apple-icon-precomposed.png
new file mode 100644
index 0000000..8e17e9c
--- /dev/null
+++ b/js/leibovitz/apple-icon-precomposed.png
Binary files differdiff --git a/js/leibovitz/apple-icon.png b/js/leibovitz/apple-icon.png
new file mode 100644
index 0000000..8e17e9c
--- /dev/null
+++ b/js/leibovitz/apple-icon.png
Binary files differdiff --git a/js/leibovitz/balance.js b/js/leibovitz/balance.js
new file mode 100644
index 0000000..41f1a89
--- /dev/null
+++ b/js/leibovitz/balance.js
@@ -0,0 +1,153 @@
+/**
+ * White balance management module implementing temperature-based color adjustment.
+ * 
+ * Implements white balance adjustment using temperature-based RGB channel scaling.
+ * Provides non-linear temperature adjustment for natural color correction.
+ * 
+ * Implements the following design patterns:
+ * - Observer Pattern: state management and effect application
+ * - Factory Pattern: UI initialization
+ * - Strategy Pattern: temperature adjustment algorithm
+ * - Command Pattern: state reset operations
+ * 
+ * White balance adjustment process:
+ * 1. Convert temperature to ratio relative to neutral (6500K)
+ * 2. Apply non-linear scaling (0.2 factor) to red and blue channels
+ * 3. Warmer temps (<6500K) increase red, decrease blue
+ * 4. Cooler temps (>6500K) increase blue, decrease red
+ * 
+ * Features:
+ * - Temperature-based color adjustment
+ * - Non-linear response curve
+ * - Preserves green channel
+ * - Real-time updates
+ */
+
+const BalanceManager = {
+    // Private state
+    _observers: new Set(),
+    _slider: null,
+    _value: null,
+
+    /**
+     * Initializes the balance manager and sets up UI controls
+     */
+    init() {
+        this._slider = document.getElementById('balance-slider');
+        this._value = document.getElementById('balance-value');
+        this._setupEventListeners();
+    },
+
+    _setupEventListeners() {
+        this._slider.addEventListener('input', () => {
+            const value = this._slider.value;
+            this._value.textContent = `${value}K`;
+            this._notifyObservers();
+        });
+    },
+
+    _notifyObservers() {
+        this._observers.forEach(observer => observer(this.getCurrentBalance()));
+    },
+
+    /**
+     * Subscribes to balance state changes
+     * @param {Function} observer - Callback function for state changes
+     * @returns {Function} Unsubscribe function
+     */
+    subscribe(observer) {
+        this._observers.add(observer);
+        return () => this._observers.delete(observer);
+    },
+
+    /**
+     * Gets the current white balance temperature
+     * @returns {number} Current temperature in Kelvin (2000K-10000K)
+     */
+    getCurrentBalance() {
+        return parseInt(this._slider.value);
+    },
+
+    /**
+     * Applies white balance adjustment to an image
+     * And implements temperature-based RGB channel scaling with non-linear response
+     * @param {ImageData} imageData - Source image data
+     * @returns {ImageData} White balanced image data
+     */
+    applyBalance(imageData) {
+        // Input validation
+        if (!imageData || !imageData.data || !(imageData.data instanceof Uint8ClampedArray)) {
+            console.warn('BalanceManager: Invalid ImageData provided to applyBalance');
+            return imageData;
+        }
+        
+        const balance = this.getCurrentBalance();
+        if (!balance || balance === 6500) return imageData; // 6500K is neutral
+
+        const data = imageData.data;
+        const temperature = balance / 6500; // Convert to temperature ratio
+
+        for (let i = 0; i < data.length; i += 4) {
+            // Adjust red and blue channels based on temperature
+            // Warmer (lower K) increases red, decreases blue
+            // Cooler (higher K) increases blue, decreases red
+            data[i] = Math.min(255, data[i] * (1 + (temperature - 1) * 0.2));     // Red
+            data[i + 2] = Math.min(255, data[i + 2] * (1 + (1 - temperature) * 0.2)); // Blue
+        }
+
+        return imageData;
+    },
+
+    /**
+     * Applies white balance adjustment in-place to avoid memory allocations
+     * Used by optimized effect pipeline for better performance
+     * 
+     * FUTURE WebGL OPPORTUNITY:
+     * - Temperature-based RGB scaling is perfect for GPU
+     * - Simple fragment shader with 2 uniform multipliers (red and blue channels)
+     * - Would be ~50x faster on GPU with minimal complexity
+     * - Cross-platform concern: Excellent - basic arithmetic operations work on all GPUs
+     * 
+     * FUTURE LOOKUP TABLE OPPORTUNITY:
+     * - Temperature range is limited (2000K-10000K in 100K steps = 80 entries)
+     * - Could pre-calculate RGB multipliers for all temperature values
+     * - Trade 240 bytes of memory for eliminating division operations per pixel
+     * - Extremely low memory cost with significant CPU savings
+     * 
+     * @param {ImageData} imageData - Image data to modify in-place
+     */
+    applyBalanceInPlace(imageData) {
+        // Input validation
+        if (!imageData || !imageData.data || !(imageData.data instanceof Uint8ClampedArray)) {
+            console.warn('BalanceManager: Invalid ImageData provided to applyBalanceInPlace');
+            return;
+        }
+        
+        const balance = this.getCurrentBalance();
+        if (!balance || balance === 6500) return; // 6500K is neutral
+
+        const data = imageData.data;
+        const temperature = balance / 6500; // Convert to temperature ratio
+
+        // Pre-calculate multipliers to avoid division in inner loop
+        const redMultiplier = 1 + (temperature - 1) * 0.2;
+        const blueMultiplier = 1 + (1 - temperature) * 0.2;
+
+        // Process pixels in-place to avoid memory allocation
+        for (let i = 0; i < data.length; i += 4) {
+            // Adjust red and blue channels based on temperature
+            data[i] = Math.min(255, data[i] * redMultiplier);     // Red
+            data[i + 2] = Math.min(255, data[i + 2] * blueMultiplier); // Blue
+            // Green channel (data[i + 1]) remains unchanged
+        }
+    },
+
+    /**
+     * Resets balance effect to default state
+     */
+    reset() {
+        this._slider.value = 6500;
+        this._value.textContent = '6500K';
+        this._notifyObservers();
+    }
+}; 
\ No newline at end of file
diff --git a/js/leibovitz/blur.js b/js/leibovitz/blur.js
new file mode 100644
index 0000000..3d296e9
--- /dev/null
+++ b/js/leibovitz/blur.js
@@ -0,0 +1,301 @@
+/**
+ * Blur management module implementing optimized box blur algorithm.
+ * 
+ * Implements a two-pass box blur algorithm with sliding window optimization.
+ * Uses subsampling for large radii to maintain real-time performance.
+ * 
+ * FUTURE WebGL ACCELERATION OPPORTUNITY (HIGHEST PRIORITY):
+ * - Blur is the MOST computationally expensive effect in the pipeline
+ * - Perfect candidate for GPU acceleration using separable convolution
+ * - Implementation: Two-pass shader (horizontal + vertical) with ping-pong framebuffers
+ * - Expected performance: 10-50x faster depending on radius and device
+ * - Cross-platform concerns:
+ *   * WebGL 1.0 supported on 98%+ devices (excellent compatibility)
+ *   * Separable blur is well-supported on all GPU architectures
+ *   * Fallback: Current CPU implementation for unsupported devices
+ * - Memory usage: 2 additional framebuffers (manageable on modern devices)
+ * - Precision: 8-bit precision sufficient for visual quality
+ * 
+ * FUTURE LOOKUP TABLE OPPORTUNITY:
+ * - Sliding window coefficients could be pre-calculated
+ * - Gaussian weights for higher-quality blur (vs current box blur)
+ * - Trade: ~1KB memory for eliminating repeated calculations
+ * - Impact: Moderate (blur is already well-optimized)
+ * 
+ * Implements the following design patterns:
+ * - Observer Pattern: state management and effect application
+ * - Factory Pattern: UI initialization
+ * - Strategy Pattern: blur algorithm implementation (sliding window vs subsampled)
+ * - Command Pattern: state reset operations
+ * 
+ * Current optimization features:
+ * - Sliding window algorithm (O(n) vs O(n*r) complexity)
+ * - Subsampling for large radii (maintains real-time performance)
+ * - Buffer reuse to minimize memory allocations
+ */
+
+const BlurManager = {
+    // Private state
+    _currentBlur: 0, // Default blur (no blur)
+    _observers: new Set(),
+    _slider: null,
+    _value: null,
+
+    init() {
+        this._slider = document.getElementById('blur-slider');
+        this._value = document.getElementById('blur-value');
+        this._setupEventListeners();
+    },
+
+    _setupEventListeners() {
+        this._slider.addEventListener('input', () => {
+            const value = this._slider.value;
+            this._value.textContent = `${value}%`;
+            this._currentBlur = parseInt(value);
+            this._notifyObservers();
+        });
+    },
+
+    _notifyObservers() {
+        this._observers.forEach(observer => observer(this._currentBlur));
+    },
+
+    /**
+     * Subscribes to blur state changes
+     * @param {Function} observer - Callback function for state changes
+     * @returns {Function} Unsubscribe function
+     */
+    subscribe(observer) {
+        this._observers.add(observer);
+        return () => this._observers.delete(observer);
+    },
+
+    getCurrentBlur() {
+        return this._currentBlur;
+    },
+
+    /**
+     * Applies highly optimized box blur using sliding window algorithm
+     * Uses advanced optimizations: sliding window, subsampling, and early exits
+     * @param {ImageData} imageData - Source image data
+     * @param {number} radius - Blur radius
+     * @returns {ImageData} Blurred image data
+     */
+    applyBlur(imageData, radius) {
+        // Input validation
+        if (!imageData || !imageData.data || !(imageData.data instanceof Uint8ClampedArray)) {
+            console.warn('BlurManager: Invalid ImageData provided to applyBlur');
+            return imageData;
+        }
+        
+        if (typeof radius !== 'number' || isNaN(radius) || radius < 0) {
+            console.warn('BlurManager: Invalid radius provided to applyBlur:', radius);
+            return imageData;
+        }
+        
+        if (!radius || radius < 1) return imageData;
+
+        const { data, width, height } = imageData;
+        
+        // For very large blur radii, use subsampling for better performance
+        if (radius > 10) {
+            return this._subsampledBlur(imageData, radius);
+        }
+        
+        // Use sliding window optimization for medium blur radii
+        return this._slidingWindowBlur(imageData, radius);
+    },
+
+    /**
+     * Subsampled blur for large radii - processes at lower resolution then upscales
+     * @param {ImageData} imageData - Source image data  
+     * @param {number} radius - Blur radius
+     * @returns {ImageData} Blurred image data
+     */
+    _subsampledBlur(imageData, radius) {
+        const { data, width, height } = imageData;
+        
+        // Calculate subsampling factor based on radius
+        const subsample = Math.max(2, Math.floor(radius / 8));
+        const newWidth = Math.ceil(width / subsample);
+        const newHeight = Math.ceil(height / subsample);
+        const scaledRadius = Math.max(1, Math.floor(radius / subsample));
+        
+        // Create downsampled image
+        const smallData = new Uint8ClampedArray(newWidth * newHeight * 4);
+        for (let y = 0; y < newHeight; y++) {
+            for (let x = 0; x < newWidth; x++) {
+                const srcX = Math.min(x * subsample, width - 1);
+                const srcY = Math.min(y * subsample, height - 1);
+                const srcIdx = (srcY * width + srcX) * 4;
+                const dstIdx = (y * newWidth + x) * 4;
+                
+                smallData[dstIdx] = data[srcIdx];
+                smallData[dstIdx + 1] = data[srcIdx + 1];
+                smallData[dstIdx + 2] = data[srcIdx + 2];
+                smallData[dstIdx + 3] = data[srcIdx + 3];
+            }
+        }
+        
+        // Apply blur to small image
+        const smallImageData = new ImageData(smallData, newWidth, newHeight);
+        this._slidingWindowBlur(smallImageData, scaledRadius);
+        
+        // Upsample back to original size with bilinear interpolation
+        for (let y = 0; y < height; y++) {
+            for (let x = 0; x < width; x++) {
+                const srcX = (x / width) * (newWidth - 1);
+                const srcY = (y / height) * (newHeight - 1);
+                
+                const x1 = Math.floor(srcX);
+                const y1 = Math.floor(srcY);
+                const x2 = Math.min(x1 + 1, newWidth - 1);
+                const y2 = Math.min(y1 + 1, newHeight - 1);
+                
+                const fx = srcX - x1;
+                const fy = srcY - y1;
+                
+                const idx1 = (y1 * newWidth + x1) * 4;
+                const idx2 = (y1 * newWidth + x2) * 4;
+                const idx3 = (y2 * newWidth + x1) * 4;
+                const idx4 = (y2 * newWidth + x2) * 4;
+                
+                const dstIdx = (y * width + x) * 4;
+                
+                for (let c = 0; c < 4; c++) {
+                    const top = smallData[idx1 + c] * (1 - fx) + smallData[idx2 + c] * fx;
+                    const bottom = smallData[idx3 + c] * (1 - fx) + smallData[idx4 + c] * fx;
+                    data[dstIdx + c] = top * (1 - fy) + bottom * fy;
+                }
+            }
+        }
+        
+        return imageData;
+    },
+
+    /**
+     * Sliding window blur - extremely fast for small to medium radii
+     * @param {ImageData} imageData - Source image data
+     * @param {number} radius - Blur radius  
+     * @returns {ImageData} Blurred image data
+     */
+    _slidingWindowBlur(imageData, radius) {
+        const { data, width, height } = imageData;
+        const tempData = new Uint8ClampedArray(data);
+        
+        // Horizontal pass with sliding window
+        for (let y = 0; y < height; y++) {
+            const rowOffset = y * width * 4;
+            let r = 0, g = 0, b = 0, a = 0;
+            let count = 0;
+            
+            // Initialize window for first pixel
+            const startX = Math.max(0, -radius);
+            const endX = Math.min(width - 1, radius);
+            
+            for (let x = startX; x <= endX; x++) {
+                const i = rowOffset + x * 4;
+                r += data[i];
+                g += data[i + 1];
+                b += data[i + 2];
+                a += data[i + 3];
+                count++;
+            }
+            
+            // Process each pixel using sliding window
+            for (let x = 0; x < width; x++) {
+                // Add new pixel to window (if exists)
+                const addX = x + radius + 1;
+                if (addX < width) {
+                    const i = rowOffset + addX * 4;
+                    r += data[i];
+                    g += data[i + 1];
+                    b += data[i + 2];
+                    a += data[i + 3];
+                    count++;
+                }
+                
+                // Remove old pixel from window (if exists)
+                const removeX = x - radius;
+                if (removeX >= 0) {
+                    const i = rowOffset + removeX * 4;
+                    r -= data[i];
+                    g -= data[i + 1];
+                    b -= data[i + 2];
+                    a -= data[i + 3];
+                    count--;
+                }
+                
+                // Store result
+                const i = rowOffset + x * 4;
+                const invCount = 1.0 / count;
+                tempData[i] = r * invCount;
+                tempData[i + 1] = g * invCount;
+                tempData[i + 2] = b * invCount;
+                tempData[i + 3] = a * invCount;
+            }
+        }
+        
+        // Vertical pass with sliding window
+        for (let x = 0; x < width; x++) {
+            const colOffset = x * 4;
+            let r = 0, g = 0, b = 0, a = 0;
+            let count = 0;
+            
+            // Initialize window for first pixel
+            const startY = Math.max(0, -radius);
+            const endY = Math.min(height - 1, radius);
+            
+            for (let y = startY; y <= endY; y++) {
+                const i = y * width * 4 + colOffset;
+                r += tempData[i];
+                g += tempData[i + 1];
+                b += tempData[i + 2];
+                a += tempData[i + 3];
+                count++;
+            }
+            
+            // Process each pixel using sliding window
+            for (let y = 0; y < height; y++) {
+                // Add new pixel to window (if exists)
+                const addY = y + radius + 1;
+                if (addY < height) {
+                    const i = addY * width * 4 + colOffset;
+                    r += tempData[i];
+                    g += tempData[i + 1];
+                    b += tempData[i + 2];
+                    a += tempData[i + 3];
+                    count++;
+                }
+                
+                // Remove old pixel from window (if exists)
+                const removeY = y - radius;
+                if (removeY >= 0) {
+                    const i = removeY * width * 4 + colOffset;
+                    r -= tempData[i];
+                    g -= tempData[i + 1];
+                    b -= tempData[i + 2];
+                    a -= tempData[i + 3];
+                    count--;
+                }
+                
+                // Store result
+                const i = y * width * 4 + colOffset;
+                const invCount = 1.0 / count;
+                data[i] = r * invCount;
+                data[i + 1] = g * invCount;
+                data[i + 2] = b * invCount;
+                data[i + 3] = a * invCount;
+            }
+        }
+        
+        return imageData;
+    },
+
+    reset() {
+        this._currentBlur = 0;
+        this._slider.value = 0;
+        this._value.textContent = '0%';
+        this._notifyObservers();
+    }
+}; 
\ No newline at end of file
diff --git a/js/leibovitz/browserconfig.xml b/js/leibovitz/browserconfig.xml
new file mode 100644
index 0000000..c554148
--- /dev/null
+++ b/js/leibovitz/browserconfig.xml
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>
\ No newline at end of file
diff --git a/js/leibovitz/color.js b/js/leibovitz/color.js
new file mode 100644
index 0000000..bd1ea61
--- /dev/null
+++ b/js/leibovitz/color.js
@@ -0,0 +1,322 @@
+/**
+ * Color tint management module implementing HSL-based color manipulation.
+ * 
+ * Implements color tinting using HSL color space transformation with circular interpolation.
+ * Features noise reduction and smooth blending for high-quality results.
+ * 
+ * Implements the following design patterns:
+ * - Observer Pattern: state management and effect application
+ * - Factory Pattern: UI initialization
+ * - Strategy Pattern: color manipulation algorithms
+ * - Command Pattern: state reset operations
+ * 
+ * Color manipulation process:
+ * 1. Convert RGB to HSL color space
+ * 2. Apply circular interpolation for hue blending
+ * 3. Smooth blending for saturation and lightness
+ * 4. Noise reduction through value rounding
+ * 5. Convert back to RGB color space
+ * 
+ * Features:
+ * - Circular interpolation for natural hue transitions
+ * - Noise reduction through value rounding
+ * - Smooth blending with quadratic easing
+ * - HSL color space for better color manipulation
+ */
+
+const ColorManager = {
+    // Private state
+    _currentColor: null,
+    _observers: new Set(),
+    _colorInput: null,
+
+    init() {
+        this._setupEventListeners();
+    },
+
+    _setupEventListeners() {
+        this._colorInput = document.getElementById('color-tint');
+        this._colorInput.addEventListener('input', (e) => {
+            this._currentColor = e.target.value;
+            this._notifyObservers();
+        });
+
+        const resetButton = document.getElementById('reset-color');
+        resetButton.addEventListener('click', () => {
+            // Reset color tint
+            this._currentColor = null;
+            this._colorInput.value = '#ffffff';
+            this._notifyObservers();
+
+            // Reset contrast
+            ContrastManager.reset();
+
+            // Reset blur
+            BlurManager.reset();
+
+            // Reset white balance to default (6500K)
+            const balanceSlider = document.getElementById('balance-slider');
+            const balanceValue = document.getElementById('balance-value');
+            if (balanceSlider && balanceValue) {
+                balanceSlider.value = 6500;
+                balanceValue.textContent = '6500K';
+            }
+        });
+    },
+
+    _notifyObservers() {
+        this._observers.forEach(observer => observer(this._currentColor));
+    },
+
+    /**
+     * Subscribes to color state changes
+     * @param {Function} observer - Callback function for state changes
+     * @returns {Function} Unsubscribe function
+     */
+    subscribe(observer) {
+        this._observers.add(observer);
+        return () => this._observers.delete(observer);
+    },
+
+    getCurrentColor() {
+        return this._currentColor;
+    },
+
+    /**
+     * Applies color tint to an image using HSL color space
+     * Uses noise reduction and smooth blending for quality
+     * @param {ImageData} imageData - Source image data
+     * @param {string} color - Hex color value
+     * @returns {ImageData} Tinted image data
+     */
+    applyTint(imageData, color) {
+        if (!color) return imageData;
+
+        const { data } = imageData;
+        const [tintR, tintG, tintB] = this._hexToRgb(color);
+        
+        // Convert tint color to HSL for better color manipulation
+        const [tintH, tintS, tintL] = this._rgbToHsl(tintR, tintG, tintB);
+        
+        // Apply tint to each pixel with reduced noise
+        for (let i = 0; i < data.length; i += 4) {
+            const r = data[i];
+            const g = data[i + 1];
+            const b = data[i + 2];
+            
+            // Convert pixel to HSL
+            const [h, s, l] = this._rgbToHsl(r, g, b);
+            
+            // Blend the tint color with the original color
+            // This tries to create a more natural LUT effect
+            const blendFactor = 0.15; // Reduced from 0.3 to 0.15 for smoother effect
+            
+            // Smooth blending for hue (circular interpolation)
+            const newH = this._blendHue(h, tintH, blendFactor);
+            
+            // Smooth blending for saturation and lightness with noise reduction
+            const newS = this._smoothBlend(s, tintS, blendFactor);
+            const newL = this._smoothBlend(l, tintL, blendFactor);
+            
+            // Convert back to RGB with noise reduction
+            const [newR, newG, newB] = this._hslToRgb(newH, newS, newL);
+            
+            // Apply noise reduction by rounding to nearest multiple of 4
+            data[i] = Math.round(newR / 4) * 4;
+            data[i + 1] = Math.round(newG / 4) * 4;
+            data[i + 2] = Math.round(newB / 4) * 4;
+        }
+
+        return imageData;
+    },
+
+    /**
+     * Validates hex color format
+     * @param {string} hex - Hex color string to validate
+     * @returns {boolean} True if valid hex color
+     */
+    _isValidHexColor(hex) {
+        return /^#?([a-f\d]{2}){3}$/i.test(hex);
+    },
+
+    /**
+     * Converts hex color to RGB values
+     * @param {string} hex - Hex color string
+     * @returns {Array} RGB values [r, g, b]
+     */
+    _hexToRgb(hex) {
+        const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
+        return result ? [
+            parseInt(result[1], 16),
+            parseInt(result[2], 16),
+            parseInt(result[3], 16)
+        ] : null;
+    },
+
+    /**
+     * Converts RGB to HSL color space
+     * @param {number} r - Red component
+     * @param {number} g - Green component
+     * @param {number} b - Blue component
+     * @returns {Array} HSL values [h, s, l]
+     */
+    _rgbToHsl(r, g, b) {
+        r /= 255;
+        g /= 255;
+        b /= 255;
+        
+        const max = Math.max(r, g, b);
+        const min = Math.min(r, g, b);
+        let h, s, l = (max + min) / 2;
+        
+        if (max === min) {
+            h = s = 0;
+        } else {
+            const d = max - min;
+            s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+            
+            switch (max) {
+                case r: h = (g - b) / d + (g < b ? 6 : 0); break;
+                case g: h = (b - r) / d + 2; break;
+                case b: h = (r - g) / d + 4; break;
+            }
+            h /= 6;
+        }
+        
+        return [h, s, l];
+    },
+
+    /**
+     * Converts HSL to RGB color space
+     * @param {number} h - Hue component
+     * @param {number} s - Saturation component
+     * @param {number} l - Lightness component
+     * @returns {Array} RGB values [r, g, b]
+     */
+    _hslToRgb(h, s, l) {
+        let r, g, b;
+        
+        if (s === 0) {
+            r = g = b = l;
+        } else {
+            const hue2rgb = (p, q, t) => {
+                if (t < 0) t += 1;
+                if (t > 1) t -= 1;
+                if (t < 1/6) return p + (q - p) * 6 * t;
+                if (t < 1/2) return q;
+                if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
+                return p;
+            };
+            
+            const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
+            const p = 2 * l - q;
+            
+            r = hue2rgb(p, q, h + 1/3);
+            g = hue2rgb(p, q, h);
+            b = hue2rgb(p, q, h - 1/3);
+        }
+        
+        return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
+    },
+
+    /**
+     * Blends two hue values with circular interpolation
+     * @param {number} h1 - First hue value
+     * @param {number} h2 - Second hue value
+     * @param {number} factor - Blend factor
+     * @returns {number} Blended hue value
+     */
+    _blendHue(h1, h2, factor) {
+        const diff = h2 - h1;
+        if (Math.abs(diff) > 0.5) {
+            if (h1 > h2) {
+                return h1 + (h2 + 1 - h1) * factor;
+            } else {
+                return h1 + (h2 - (h1 + 1)) * factor;
+            }
+        }
+        return h1 + diff * factor;
+    },
+
+    /**
+     * Smooth blending with noise reduction
+     * Uses cubic easing function for smooth transitions
+     * @param {number} v1 - First value
+     * @param {number} v2 - Second value
+     * @param {number} factor - Blend factor
+     * @returns {number} Blended value
+     */
+    _smoothBlend(v1, v2, factor) {
+        // Apply a smooth easing function
+        const t = factor * factor * (3 - 2 * factor);
+        return v1 + (v2 - v1) * t;
+    },
+
+    /**
+     * Applies color tint in-place to avoid memory allocations
+     * Used by optimized effect pipeline for better performance
+     * 
+     * FUTURE WebGL OPPORTUNITY:
+     * - HSL conversion and tinting could be done in fragment shader
+     * - GPU has dedicated texture units for efficient color space operations
+     * - Would eliminate ~6M color space conversions per frame at 1080p
+     * - Cross-platform concern: Complex HSL math might have precision issues on older GPUs
+     * 
+     * FUTURE LOOKUP TABLE OPPORTUNITY:
+     * - RGB→HSL and HSL→RGB conversions are expensive (sin/cos operations)
+     * - Could pre-calculate 256³ lookup tables for common color ranges
+     * - Trade memory (~48MB) for CPU cycles (eliminates trigonometric operations)
+     * - Alternative: 256-entry tables for each channel with interpolation (768KB total)
+     * 
+     * @param {ImageData} imageData - Image data to modify in-place
+     * @param {string} color - Hex color value for tinting
+     */
+    applyTintInPlace(imageData, color) {
+        // Input validation
+        if (!imageData || !imageData.data || !(imageData.data instanceof Uint8ClampedArray)) {
+            console.warn('ColorManager: Invalid ImageData provided to applyTintInPlace');
+            return;
+        }
+        
+        if (!color || typeof color !== 'string' || !this._isValidHexColor(color)) {
+            console.warn('ColorManager: Invalid color provided to applyTintInPlace:', color);
+            return;
+        }
+        
+        if (!color) return;
+
+        const { data } = imageData;
+        const [tintR, tintG, tintB] = this._hexToRgb(color);
+        
+        // Convert tint color to HSL for better color manipulation
+        const [tintH, tintS, tintL] = this._rgbToHsl(tintR, tintG, tintB);
+        
+        // Apply tint to each pixel in-place with reduced noise
+        for (let i = 0; i < data.length; i += 4) {
+            const r = data[i];
+            const g = data[i + 1];
+            const b = data[i + 2];
+            
+            // Convert pixel to HSL
+            const [h, s, l] = this._rgbToHsl(r, g, b);
+            
+            // Blend the tint color with the original color
+            const blendFactor = 0.15; // Reduced for smoother effect
+            
+            // Smooth blending for hue (circular interpolation)
+            const newH = this._blendHue(h, tintH, blendFactor);
+            
+            // Smooth blending for saturation and lightness with noise reduction
+            const newS = this._smoothBlend(s, tintS, blendFactor);
+            const newL = this._smoothBlend(l, tintL, blendFactor);
+            
+            // Convert back to RGB with noise reduction
+            const [newR, newG, newB] = this._hslToRgb(newH, newS, newL);
+            
+            // Apply noise reduction by rounding to nearest multiple of 4
+            data[i] = Math.round(newR / 4) * 4;
+            data[i + 1] = Math.round(newG / 4) * 4;
+            data[i + 2] = Math.round(newB / 4) * 4;
+        }
+    }
+}; 
\ No newline at end of file
diff --git a/js/leibovitz/contrast.js b/js/leibovitz/contrast.js
new file mode 100644
index 0000000..c77200e
--- /dev/null
+++ b/js/leibovitz/contrast.js
@@ -0,0 +1,134 @@
+/**
+ * Contrast management module implementing linear contrast adjustment.
+ * 
+ * Implements contrast adjustment using a linear scaling algorithm.
+ * Provides real-time contrast control with immediate visual feedback.
+ * 
+ * Implements the following design patterns:
+ * - Observer Pattern: state management and effect application
+ * - Factory Pattern: UI initialization
+ * - Strategy Pattern: contrast adjustment algorithm
+ * - Command Pattern: state reset operations
+ * 
+ * Contrast adjustment process:
+ * 1. Calculate contrast factor using formula: (259 * (contrast + 255)) / (255 * (259 - contrast))
+ * 2. Apply linear scaling to each color channel
+ * 3. Maintain color balance while adjusting contrast
+ * 
+ * Features:
+ * - Linear contrast adjustment
+ * - Per-channel processing
+ * - Real-time updates
+ * - Preserves color relationships
+ */
+
+const ContrastManager = {
+    // Private state
+    _currentContrast: 1.0, // Default contrast (no change)
+    _observers: new Set(),
+    _slider: null,
+
+    /**
+     * Initializes the contrast manager and sets up UI controls
+     */
+    init() {
+        this._setupEventListeners();
+    },
+
+    /**
+     * Sets up event listeners for UI controls
+     */
+    _setupEventListeners() {
+        this._slider = document.getElementById('contrast-slider');
+        this._slider.addEventListener('input', (e) => {
+            this._currentContrast = parseFloat(e.target.value);
+            document.getElementById('contrast-value').textContent = this._currentContrast;
+            this._notifyObservers();
+        });
+    },
+
+    _notifyObservers() {
+        this._observers.forEach(observer => observer(this._currentContrast));
+    },
+
+    /**
+     * Subscribes to contrast state changes
+     * @param {Function} observer - Callback function for state changes
+     * @returns {Function} Unsubscribe function
+     */
+    subscribe(observer) {
+        this._observers.add(observer);
+        return () => this._observers.delete(observer);
+    },
+
+    getCurrentContrast() {
+        return this._currentContrast;
+    },
+
+    /**
+     * Applies contrast adjustment to an image
+     * Implements linear contrast adjustment algorithm
+     * @param {ImageData} imageData - Source image data
+     * @param {number} contrast - Contrast value
+     * @returns {ImageData} Contrasted image data
+     */
+    applyContrast(imageData, contrast) {
+        if (!contrast || contrast === 1.0) return imageData;
+
+        const { data } = imageData;
+        const factor = (259 * (contrast + 255)) / (255 * (259 - contrast));
+
+        for (let i = 0; i < data.length; i += 4) {
+            // Apply contrast to each color channel
+            for (let c = 0; c < 3; c++) {
+                const pixel = data[i + c];
+                data[i + c] = factor * (pixel - 128) + 128;
+            }
+        }
+
+        return imageData;
+    },
+
+    /**
+     * Applies contrast adjustment in-place to avoid memory allocations
+     * Used by optimized effect pipeline for better performance
+     * 
+     * FUTURE WebGL OPPORTUNITY:
+     * - This is a perfect candidate for GPU acceleration
+     * - Simple fragment shader: gl_FragColor = (inputColor - 0.5) * contrast + 0.5
+     * - Would be ~100x faster on GPU and eliminate CPU processing entirely
+     * - Cross-platform concern: WebGL 1.0 supported on 98%+ of devices since 2015
+     * 
+     * FUTURE LOOKUP TABLE OPPORTUNITY:
+     * - For real-time use, could pre-calculate contrast curves
+     * - 256-entry lookup table per channel would eliminate multiplication per pixel
+     * - Trade memory (768 bytes) for CPU cycles (eliminates ~2M multiplications per frame)
+     * 
+     * @param {ImageData} imageData - Image data to modify in-place
+     * @param {number} contrast - Contrast adjustment value
+     */
+    applyContrastInPlace(imageData, contrast) {
+        if (!contrast || contrast === 1.0) return;
+
+        const { data } = imageData;
+        const factor = (259 * (contrast + 255)) / (255 * (259 - contrast));
+
+        // Process pixels in-place to avoid memory allocation
+        for (let i = 0; i < data.length; i += 4) {
+            // Apply contrast to each color channel
+            for (let c = 0; c < 3; c++) {
+                const pixel = data[i + c];
+                data[i + c] = factor * (pixel - 128) + 128;
+            }
+        }
+    },
+
+    /**
+     * Resets contrast effect to default state
+     */
+    reset() {
+        this._currentContrast = 1.0;
+        this._slider.value = 0; // Reset slider to middle position
+        this._notifyObservers();
+    }
+}; 
\ No newline at end of file
diff --git a/js/leibovitz/dither.js b/js/leibovitz/dither.js
new file mode 100644
index 0000000..d02802d
--- /dev/null
+++ b/js/leibovitz/dither.js
@@ -0,0 +1,969 @@
+/**
+ * Dithering management module implementing multiple dithering algorithms.
+ * 
+ * Implements multiple dithering algorithms optimized for real-time processing.
+ * Uses block-based processing and specialized algorithms for different visual effects.
+ * 
+ * Implements the following design patterns:
+ * - Observer Pattern: state management and effect application
+ * - Factory Pattern: UI initialization
+ * - Strategy Pattern: dithering algorithm selection
+ * - Command Pattern: state reset operations
+ * 
+ * Supported dithering algorithms:
+ * - Floyd-Steinberg: Error diffusion with standard distribution pattern
+ * - Ordered: Matrix-based threshold dithering  
+ * - Atkinson: Error diffusion with 1/8 error distribution
+ * - Bayer: Pattern-based threshold dithering
+ * - Pico Cam: Game Boy camera style with downscaling
+ * 
+ * Current optimization features:
+ * - Block-based processing for Floyd-Steinberg/Atkinson
+ * - Inlined error distribution (eliminates function call overhead)
+ * - Efficient downscaling/upscaling for Pico Cam effect
+ * - Reusable helper functions to minimize code duplication
+ */
+
+const DitherManager = {
+    // Private state
+    _currentMode: 'none',
+    _observers: new Set(),
+    _modeSelect: null,
+    _pixelSizeControl: null,
+    currentBlockSize: 4,
+
+    /**
+     * Initializes the dither manager and sets up UI controls
+     */
+    init() {
+        this._setupEventListeners();
+        this._pixelSizeControl = document.getElementById('pixel-size-control');
+    },
+
+    _setupEventListeners() {
+        this._modeSelect = document.getElementById('dither-select');
+        this._modeSelect.addEventListener('change', (e) => {
+            this._currentMode = e.target.value;
+            
+            // Set appropriate default pixel size for Pico Cam
+            if (this._currentMode === 'pico-cam') {
+                // Game Boy camera default pixel size (original resolution / target resolution)
+                // This gives us a reasonable default that users can then adjust
+                const defaultPicoPixelSize = Math.max(2, Math.min(8, Math.round(this.currentBlockSize / 2)));
+                this.currentBlockSize = defaultPicoPixelSize;
+                
+                // Update the slider to reflect the new value
+                const blockSizeSlider = document.getElementById('block-size-slider');
+                if (blockSizeSlider) {
+                    blockSizeSlider.value = defaultPicoPixelSize;
+                    document.getElementById('block-size-value').textContent = `${defaultPicoPixelSize}px`;
+                }
+            }
+            
+            // Show/hide pixel size control based on dithering mode
+            this._pixelSizeControl.style.display = 
+                this._currentMode === 'none' ? 'none' : 'flex';
+            this._notifyObservers();
+        });
+
+        // Only add the event listener if the element actually exists
+        const blockSizeSlider = document.getElementById('block-size-slider');
+        if (blockSizeSlider) {
+            blockSizeSlider.addEventListener('input', (e) => {
+                this.currentBlockSize = parseInt(e.target.value);
+                document.getElementById('block-size-value').textContent = 
+                    `${this.currentBlockSize}px`;
+                // Notify observers instead of directly calling processFrame
+                this._notifyObservers();
+            });
+        }
+    },
+
+    _notifyObservers() {
+        this._observers.forEach(observer => observer(this._currentMode));
+    },
+
+    /**
+     * Subscribes to dithering state changes
+     * @param {Function} observer - Callback function for state changes
+     * @returns {Function} Unsubscribe function
+     */
+    subscribe(observer) {
+        this._observers.add(observer);
+        return () => this._observers.delete(observer);
+    },
+
+    getCurrentMode() {
+        return this._currentMode;
+    },
+
+    /**
+     * Gets the effective pixel size for the current dithering mode
+     * For Pico Cam, this represents the chunkiness of the pixels
+     * @returns {number} Effective pixel size in pixels
+     */
+    getEffectivePixelSize() {
+        if (this._currentMode === 'pico-cam') {
+            // For Pico Cam, the effective pixel size is the block size
+            // This determines how chunky the pixels appear
+            return this.currentBlockSize;
+        }
+        // For other dithering modes, return the block size used for processing
+        return this.currentBlockSize;
+    },
+
+    /**
+     * Applies selected dithering algorithm to image data
+     * @param {ImageData} imageData - Source image data
+     * @param {string} mode - Selected dithering algorithm
+     * @param {string} colorTint - Optional color tint for Pico Cam mode
+     * @returns {ImageData} Processed image data
+     */
+    applyDither(imageData, mode, colorTint = null) {
+        // Input validation
+        if (!imageData || !imageData.data || !(imageData.data instanceof Uint8ClampedArray)) {
+            console.warn('DitherManager: Invalid ImageData provided to applyDither');
+            return imageData;
+        }
+        
+        if (!mode || typeof mode !== 'string') {
+            console.warn('DitherManager: Invalid mode provided to applyDither:', mode);
+            return imageData;
+        }
+        
+        if (colorTint && (typeof colorTint !== 'string' || !this._isValidHexColor(colorTint))) {
+            console.warn('DitherManager: Invalid colorTint provided to applyDither:', colorTint);
+            colorTint = null; // Fallback to no tint
+        }
+        
+        if (!mode || mode === 'none') return imageData;
+
+        const { data } = imageData;
+        const width = imageData.width;
+        const height = imageData.height;
+
+        switch (mode) {
+            case 'floyd-steinberg':
+                return this._floydSteinbergDither(data, width, height);
+            case 'ordered':
+                return this._orderedDither(data, width, height);
+            case 'atkinson':
+                return this._atkinsonDither(data, width, height);
+            case 'bayer':
+                return this._bayerDither(data, width, height);
+            case 'pico-cam':
+                return this._picoCamDither(data, width, height, colorTint);
+            default:
+                return imageData;
+        }
+    },
+
+    /**
+     * Quantizes a value to create chunkier output
+     * @param {number} value - Input value
+     * @param {number} levels - Number of quantization levels
+     * @returns {number} Quantized value
+     */
+    _quantize(value, levels = 4) {
+        const step = 255 / (levels - 1);
+        return Math.round(value / step) * step;
+    },
+
+    /**
+     * Applies Floyd-Steinberg dithering algorithm
+     * Uses a 4x4 error distribution pattern for smoother results
+     * @param {Uint8ClampedArray} data - Image data
+     * @param {number} width - Image width
+     * @param {number} height - Image height
+     * @returns {ImageData} Dithered image data
+     */
+    _floydSteinbergDither(data, width, height) {
+        const newData = new Uint8ClampedArray(data);
+        const threshold = 128;
+        const levels = 4;
+        const blockSize = this.currentBlockSize;
+
+        // Process in blocks, block by block
+        for (let y = 0; y < height; y += blockSize) {
+            for (let x = 0; x < width; x += blockSize) {
+                // Calculate block average
+                let blockSum = [0, 0, 0];
+                let pixelCount = 0;
+                
+                for (let by = 0; by < blockSize && y + by < height; by++) {
+                    for (let bx = 0; bx < blockSize && x + bx < width; bx++) {
+                        const idx = ((y + by) * width + (x + bx)) * 4;
+                        for (let c = 0; c < 3; c++) {
+                            blockSum[c] += newData[idx + c];
+                        }
+                        pixelCount++;
+                    }
+                }
+
+                // Calculate block average
+                const blockAvg = blockSum.map(sum => sum / pixelCount);
+                
+                // Apply dithering to the block average
+                for (let c = 0; c < 3; c++) {
+                    const oldPixel = blockAvg[c];
+                    const quantizedPixel = this._quantize(oldPixel, levels);
+                    const newPixel = quantizedPixel > threshold ? 255 : 0;
+                    const error = oldPixel - newPixel;
+
+                    // Fill the entire block with the new color
+                    for (let by = 0; by < blockSize && y + by < height; by++) {
+                        for (let bx = 0; bx < blockSize && x + bx < width; bx++) {
+                            const idx = ((y + by) * width + (x + bx)) * 4;
+                            newData[idx + c] = newPixel;
+                        }
+                    }
+
+                    // Distribute error to neighboring blocks
+                    if (x + blockSize < width) {
+                        this._distributeBlockError(newData, x + blockSize, y, c, error * 7/16, width, blockSize, height);
+                    }
+                    if (y + blockSize < height) {
+                        if (x - blockSize >= 0) {
+                            this._distributeBlockError(newData, x - blockSize, y + blockSize, c, error * 3/16, width, blockSize, height);
+                        }
+                        this._distributeBlockError(newData, x, y + blockSize, c, error * 5/16, width, blockSize, height);
+                        if (x + blockSize < width) {
+                            this._distributeBlockError(newData, x + blockSize, y + blockSize, c, error * 1/16, width, blockSize, height);
+                        }
+                    }
+                }
+            }
+        }
+
+        return new ImageData(newData, width, height);
+    },
+
+    /**
+     * Distributes error to neighboring blocks
+     * @param {Uint8ClampedArray} data - Image data
+     * @param {number} x - X coordinate
+     * @param {number} y - Y coordinate
+     * @param {number} channel - Color channel
+     * @param {number} error - Error value to distribute
+     * @param {number} width - Image width
+     * @param {number} blockSize - Size of processing blocks
+     * @param {number} height - Image height
+     */
+    _distributeBlockError(data, x, y, channel, error, width, blockSize, height) {
+        for (let by = 0; by < blockSize && y + by < height; by++) {
+            for (let bx = 0; bx < blockSize && x + bx < width; bx++) {
+                const idx = ((y + by) * width + (x + bx)) * 4;
+                data[idx + channel] = Math.max(0, Math.min(255, data[idx + channel] + error));
+            }
+        }
+    },
+
+    /**
+     * Applies ordered dithering using a Bayer matrix
+     * Uses proper threshold comparison for balanced results
+     * @param {Uint8ClampedArray} data - Image data
+     * @param {number} width - Image width
+     * @param {number} height - Image height
+     * @returns {ImageData} Dithered image data
+     */
+    _orderedDither(data, width, height) {
+        const newData = new Uint8ClampedArray(data);
+        const matrix = [
+            [0, 8, 2, 10],
+            [12, 4, 14, 6],
+            [3, 11, 1, 9],
+            [15, 7, 13, 5]
+        ];
+        const matrixSize = 4;
+        const levels = 4;
+        const blockSize = this.currentBlockSize;
+
+        // Process in blocks
+        for (let y = 0; y < height; y += blockSize) {
+            for (let x = 0; x < width; x += blockSize) {
+                // Calculate block average
+                let blockSum = [0, 0, 0];
+                let pixelCount = 0;
+                
+                for (let by = 0; by < blockSize && y + by < height; by++) {
+                    for (let bx = 0; bx < blockSize && x + bx < width; bx++) {
+                        const idx = ((y + by) * width + (x + bx)) * 4;
+                        for (let c = 0; c < 3; c++) {
+                            blockSum[c] += newData[idx + c];
+                        }
+                        pixelCount++;
+                    }
+                }
+
+                // Calculate block average
+                const blockAvg = blockSum.map(sum => sum / pixelCount);
+                
+                // Get matrix value for this block (normalized to 0-1 range)
+                const matrixX = Math.floor(x / blockSize) % matrixSize;
+                const matrixY = Math.floor(y / blockSize) % matrixSize;
+                const matrixThreshold = (matrix[matrixY][matrixX] / 16.0) * 255; // Normalize to 0-255
+
+                // Apply dithering to the block
+                for (let c = 0; c < 3; c++) {
+                    const pixel = blockAvg[c];
+                    
+                    // Use matrix threshold for comparison instead of adding to pixel value
+                    const newPixel = pixel > matrixThreshold ? 255 : 0;
+
+                    // Fill the entire block with the new color
+                    for (let by = 0; by < blockSize && y + by < height; by++) {
+                        for (let bx = 0; bx < blockSize && x + bx < width; bx++) {
+                            const idx = ((y + by) * width + (x + bx)) * 4;
+                            newData[idx + c] = newPixel;
+                        }
+                    }
+                }
+            }
+        }
+
+        return new ImageData(newData, width, height);
+    },
+
+    /**
+     * Applies Atkinson dithering algorithm
+     * Optimized version with inlined error distribution for better performance
+     * @param {Uint8ClampedArray} data - Image data
+     * @param {number} width - Image width
+     * @param {number} height - Image height
+     * @returns {ImageData} Dithered image data
+     */
+    _atkinsonDither(data, width, height) {
+        const newData = new Uint8ClampedArray(data);
+        const threshold = 128;
+        const blockSize = this.currentBlockSize;
+        const errorWeight = 1/8; // Atkinson uses 1/8 error distribution
+
+        // Process in blocks
+        for (let y = 0; y < height; y += blockSize) {
+            for (let x = 0; x < width; x += blockSize) {
+                // Calculate block average
+                let blockSum = [0, 0, 0];
+                let pixelCount = 0;
+                
+                for (let by = 0; by < blockSize && y + by < height; by++) {
+                    for (let bx = 0; bx < blockSize && x + bx < width; bx++) {
+                        const idx = ((y + by) * width + (x + bx)) * 4;
+                        for (let c = 0; c < 3; c++) {
+                            blockSum[c] += newData[idx + c];
+                        }
+                        pixelCount++;
+                    }
+                }
+
+                // Calculate block average
+                const blockAvg = blockSum.map(sum => sum / pixelCount);
+                
+                // Apply dithering to the block average
+                for (let c = 0; c < 3; c++) {
+                    const oldPixel = blockAvg[c];
+                    const newPixel = oldPixel > threshold ? 255 : 0;
+                    const error = (oldPixel - newPixel) * errorWeight;
+
+                    // Fill the entire block with the new color
+                    for (let by = 0; by < blockSize && y + by < height; by++) {
+                        for (let bx = 0; bx < blockSize && x + bx < width; bx++) {
+                            const idx = ((y + by) * width + (x + bx)) * 4;
+                            newData[idx + c] = newPixel;
+                        }
+                    }
+
+                    // Inlined error distribution for better performance
+                    // Atkinson pattern: distributes to 6 neighbors with 1/8 weight each
+                    
+                    // Right (x+1)
+                    if (x + blockSize < width) {
+                        for (let by = 0; by < blockSize && y + by < height; by++) {
+                            for (let bx = 0; bx < blockSize && x + blockSize + bx < width; bx++) {
+                                const idx = ((y + by) * width + (x + blockSize + bx)) * 4;
+                                newData[idx + c] = Math.max(0, Math.min(255, newData[idx + c] + error));
+                            }
+                        }
+                        
+                        // Right-right (x+2)
+                        if (x + blockSize * 2 < width) {
+                            for (let by = 0; by < blockSize && y + by < height; by++) {
+                                for (let bx = 0; bx < blockSize && x + blockSize * 2 + bx < width; bx++) {
+                                    const idx = ((y + by) * width + (x + blockSize * 2 + bx)) * 4;
+                                    newData[idx + c] = Math.max(0, Math.min(255, newData[idx + c] + error));
+                                }
+                            }
+                        }
+                    }
+                    
+                    // Bottom row
+                    if (y + blockSize < height) {
+                        // Bottom-left (x-1, y+1)
+                        if (x - blockSize >= 0) {
+                            for (let by = 0; by < blockSize && y + blockSize + by < height; by++) {
+                                for (let bx = 0; bx < blockSize && x - blockSize + bx >= 0; bx++) {
+                                    const idx = ((y + blockSize + by) * width + (x - blockSize + bx)) * 4;
+                                    newData[idx + c] = Math.max(0, Math.min(255, newData[idx + c] + error));
+                                }
+                            }
+                        }
+                        
+                        // Bottom (x, y+1)
+                        for (let by = 0; by < blockSize && y + blockSize + by < height; by++) {
+                            for (let bx = 0; bx < blockSize && x + bx < width; bx++) {
+                                const idx = ((y + blockSize + by) * width + (x + bx)) * 4;
+                                newData[idx + c] = Math.max(0, Math.min(255, newData[idx + c] + error));
+                            }
+                        }
+                        
+                        // Bottom-right (x+1, y+1)
+                        if (x + blockSize < width) {
+                            for (let by = 0; by < blockSize && y + blockSize + by < height; by++) {
+                                for (let bx = 0; bx < blockSize && x + blockSize + bx < width; bx++) {
+                                    const idx = ((y + blockSize + by) * width + (x + blockSize + bx)) * 4;
+                                    newData[idx + c] = Math.max(0, Math.min(255, newData[idx + c] + error));
+                                }
+                            }
+                        }
+                    }
+                    
+                    // Bottom-bottom-right (x+1, y+2)
+                    if (y + blockSize * 2 < height && x + blockSize < width) {
+                        for (let by = 0; by < blockSize && y + blockSize * 2 + by < height; by++) {
+                            for (let bx = 0; bx < blockSize && x + blockSize + bx < width; bx++) {
+                                const idx = ((y + blockSize * 2 + by) * width + (x + blockSize + bx)) * 4;
+                                newData[idx + c] = Math.max(0, Math.min(255, newData[idx + c] + error));
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        return new ImageData(newData, width, height);
+    },
+
+    /**
+     * Applies Bayer dithering algorithm
+     * @param {Uint8ClampedArray} data - Image data
+     * @param {number} width - Image width
+     * @param {number} height - Image height
+     * @returns {ImageData} Dithered image data
+     */
+    _bayerDither(data, width, height) {
+        const newData = new Uint8ClampedArray(data);
+        const blockSize = this.currentBlockSize;
+        
+        const bayerMatrix = [
+            [ 0, 8, 2, 10],
+            [12, 4, 14, 6 ],
+            [ 3, 11, 1, 9 ],
+            [15, 7, 13, 5 ]
+        ];
+        
+        const scaleFactor = 16;
+        
+        // Process in blocks
+        for (let y = 0; y < height; y += blockSize) {
+            for (let x = 0; x < width; x += blockSize) {
+                // Calculate block average
+                let blockSum = [0, 0, 0];
+                let pixelCount = 0;
+                
+                for (let by = 0; by < blockSize && y + by < height; by++) {
+                    for (let bx = 0; bx < blockSize && x + bx < width; bx++) {
+                        const idx = ((y + by) * width + (x + bx)) * 4;
+                        for (let c = 0; c < 3; c++) {
+                            blockSum[c] += newData[idx + c];
+                        }
+                        pixelCount++;
+                    }
+                }
+
+                // Calculate block average
+                const blockAvg = blockSum.map(sum => sum / pixelCount);
+                
+                // Get matrix value for this block position
+                const matrixX = Math.floor(x / blockSize) % 4;
+                const matrixY = Math.floor(y / blockSize) % 4;
+                const threshold = (bayerMatrix[matrixY][matrixX] * scaleFactor);
+                
+                // Apply dithering to the block
+                for (let c = 0; c < 3; c++) {
+                    const normalizedPixel = blockAvg[c];
+                    const newPixel = normalizedPixel > threshold ? 255 : 0;
+                    
+                    // Fill the entire block with the new color
+                    for (let by = 0; by < blockSize && y + by < height; by++) {
+                        for (let bx = 0; bx < blockSize && x + bx < width; bx++) {
+                            const idx = ((y + by) * width + (x + bx)) * 4;
+                            newData[idx + c] = newPixel;
+                        }
+                    }
+                }
+            }
+        }
+
+        // Preserve alpha channel
+        for (let i = 3; i < newData.length; i += 4) {
+            newData[i] = data[i];
+        }
+        
+        return new ImageData(newData, width, height);
+    },
+
+    /**
+     * Applies Pico Cam effect - Game Boy camera style black and white dithering
+     * Downscales to low resolution, applies dithering, then upscales for chunky pixels
+     * @param {Uint8ClampedArray} data - Image data
+     * @param {number} width - Image width
+     * @param {number} height - Image height
+     * @param {string} colorTint - Optional hex color for tinting (defaults to black/white)
+     * @returns {ImageData} Pico Cam processed image data
+     */
+    _picoCamDither(data, width, height, colorTint = null) {
+        // Use currentBlockSize to determine the effective pixel size
+        // This allows users to control the chunkiness of the Pico Cam effect
+        const blockSize = this.currentBlockSize;
+        
+        // Calculate low resolution dimensions based on block size
+        // Smaller block size = higher resolution = smaller pixels
+        // Larger block size = lower resolution = bigger pixels
+        const lowResWidth = Math.max(80, Math.floor(width / blockSize));
+        const lowResHeight = Math.max(60, Math.floor(height / blockSize));
+        
+        // Debug logging for development
+        console.log(`Pico Cam: Original: ${width}x${height}, Block Size: ${blockSize}, Low Res: ${lowResWidth}x${lowResHeight}`);
+        
+        // Calculate scaling to maintain aspect ratio
+        const aspectRatio = width / height;
+        const targetAspect = lowResWidth / lowResHeight;
+        
+        let scaledWidth, scaledHeight;
+        if (aspectRatio > targetAspect) {
+            scaledHeight = lowResHeight;
+            scaledWidth = Math.round(lowResHeight * aspectRatio);
+        } else {
+            scaledWidth = lowResWidth;
+            scaledHeight = Math.round(lowResWidth / aspectRatio);
+        }
+        
+        // Downscale image
+        const smallData = this._downscaleImage(data, width, height, scaledWidth, scaledHeight);
+        
+        // Apply grayscale conversion
+        this._convertToGrayscale(smallData);
+        
+        // Apply pixel-level Floyd-Steinberg dithering (reuse existing logic)
+        this._applyPixelLevelFloydSteinberg(smallData, scaledWidth, scaledHeight);
+        
+        // Apply color tint if provided - intelligently determines whether to replace white or black pixels
+        if (colorTint && colorTint !== '#ffffff') {
+            this._applyPicoCamColorTint(smallData, scaledWidth, scaledHeight, colorTint);
+        }
+        // If no color tint is provided, the image remains black and white (default behavior)
+        
+        // Upscale back to original size with nearest neighbor (chunky pixels)
+        return this._upscaleImage(smallData, scaledWidth, scaledHeight, width, height, data);
+    },
+
+    /**
+     * Applies color tint to a dithered black and white image for Pico Cam effect
+     * Intelligently determines whether to replace white or black pixels based on color luminance
+     * @param {Uint8ClampedArray} data - Dithered image data (black and white)
+     * @param {number} width - Image width
+     * @param {number} height - Image height
+     * @param {string} color - Hex color value to apply
+     */
+    _applyPicoCamColorTint(data, width, height, color) {
+        // Parse the hex color
+        const [tintR, tintG, tintB] = this._hexToRgb(color);
+        
+        // Calculate luminance of the tint color (same formula used for grayscale conversion)
+        const tintLuminance = 0.299 * tintR + 0.587 * tintG + 0.114 * tintB;
+        
+        // Determine whether this color is closer to white or black
+        // Threshold at 128 (middle gray) - above is closer to white, below is closer to black
+        const isCloserToWhite = tintLuminance > 128;
+        
+        // Debug logging for development
+        console.log(`Pico Cam Color Tint: ${color} (RGB: ${tintR},${tintG},${tintB}) - Luminance: ${Math.round(tintLuminance)} - ${isCloserToWhite ? 'Closer to White' : 'Closer to Black'}`);
+        
+        // Apply the color tint intelligently
+        for (let i = 0; i < data.length; i += 4) {
+            const pixelValue = data[i]; // All RGB channels are the same in grayscale
+            
+            if (isCloserToWhite) {
+                // Color is closer to white - replace white pixels with the tint color
+                if (pixelValue === 0) {
+                    // Black pixels remain very dark (10% of tint for subtle effect)
+                    data[i] = Math.round(tintR * 0.1);
+                    data[i + 1] = Math.round(tintG * 0.1);
+                    data[i + 2] = Math.round(tintB * 0.1);
+                } else {
+                    // White pixels get the full tint color
+                    data[i] = tintR;
+                    data[i + 1] = tintG;
+                    data[i + 2] = tintB;
+                }
+            } else {
+                // Color is closer to black - replace black pixels with the tint color
+                if (pixelValue === 0) {
+                    // Black pixels get the full tint color
+                    data[i] = tintR;
+                    data[i + 1] = tintG;
+                    data[i + 2] = tintB;
+                } else {
+                    // White pixels remain white (or very light version of tint for subtle effect)
+                    data[i] = Math.round(255 - (255 - tintR) * 0.1);
+                    data[i + 1] = Math.round(255 - (255 - tintG) * 0.1);
+                    data[i + 2] = Math.round(255 - (255 - tintB) * 0.1);
+                }
+            }
+        }
+    },
+
+    /**
+     * Validates hex color format
+     * @param {string} hex - Hex color string to validate
+     * @returns {boolean} True if valid hex color
+     */
+    _isValidHexColor(hex) {
+        return /^#?([a-f\d]{2}){3}$/i.test(hex);
+    },
+
+    /**
+     * Converts hex color to RGB values (helper method)
+     * @param {string} hex - Hex color string
+     * @returns {Array} RGB values [r, g, b]
+     */
+    _hexToRgb(hex) {
+        const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
+        return result ? [
+            parseInt(result[1], 16),
+            parseInt(result[2], 16),
+            parseInt(result[3], 16)
+        ] : [255, 255, 255]; // Default to white if parsing fails
+    },
+
+    /**
+     * Downscales image data using area averaging
+     * @param {Uint8ClampedArray} data - Source image data
+     * @param {number} srcWidth - Source width
+     * @param {number} srcHeight - Source height  
+     * @param {number} dstWidth - Destination width
+     * @param {number} dstHeight - Destination height
+     * @returns {Uint8ClampedArray} Downscaled image data
+     */
+    _downscaleImage(data, srcWidth, srcHeight, dstWidth, dstHeight) {
+        const smallData = new Uint8ClampedArray(dstWidth * dstHeight * 4);
+        const blockSize = this.currentBlockSize;
+        
+        for (let y = 0; y < dstHeight; y++) {
+            for (let x = 0; x < dstWidth; x++) {
+                // Sample from the center of each block for better representation
+                const srcX = Math.floor((x + 0.5) * (srcWidth / dstWidth));
+                const srcY = Math.floor((y + 0.5) * (srcHeight / dstHeight));
+                
+                // Clamp to valid range
+                const clampedSrcX = Math.max(0, Math.min(srcWidth - 1, srcX));
+                const clampedSrcY = Math.max(0, Math.min(srcHeight - 1, srcY));
+                
+                const srcIdx = (clampedSrcY * srcWidth + clampedSrcX) * 4;
+                const dstIdx = (y * dstWidth + x) * 4;
+                
+                smallData[dstIdx] = data[srcIdx];
+                smallData[dstIdx + 1] = data[srcIdx + 1];
+                smallData[dstIdx + 2] = data[srcIdx + 2];
+                smallData[dstIdx + 3] = data[srcIdx + 3];
+            }
+        }
+        
+        return smallData;
+    },
+
+    /**
+     * Converts image data to grayscale using luminance formula
+     * @param {Uint8ClampedArray} data - Image data to convert in-place
+     */
+    _convertToGrayscale(data) {
+        for (let i = 0; i < data.length; i += 4) {
+            const gray = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]);
+            data[i] = gray;
+            data[i + 1] = gray;
+            data[i + 2] = gray;
+        }
+    },
+
+    /**
+     * Applies pixel-level Floyd-Steinberg dithering (reusable)
+     * @param {Uint8ClampedArray} data - Image data
+     * @param {number} width - Image width
+     * @param {number} height - Image height
+     */
+    _applyPixelLevelFloydSteinberg(data, width, height) {
+        const getPixelIndex = (x, y) => (y * width + x) * 4;
+        const getPixelValue = (x, y) => data[getPixelIndex(x, y)];
+        const setPixelValue = (x, y, value) => {
+            const index = getPixelIndex(x, y);
+            data[index] = value;
+            data[index + 1] = value;
+            data[index + 2] = value;
+        };
+        const clamp = (value) => Math.max(0, Math.min(255, value));
+
+        for (let y = 0; y < height; y++) {
+            for (let x = 0; x < width; x++) {
+                const oldPixel = getPixelValue(x, y);
+                const newPixel = oldPixel < 128 ? 0 : 255; // Binary threshold
+                setPixelValue(x, y, newPixel);
+                
+                const quantError = oldPixel - newPixel;
+                
+                // Distribute error (Floyd-Steinberg pattern)
+                if (x + 1 < width) {
+                    const idx = getPixelIndex(x + 1, y);
+                    data[idx] = clamp(data[idx] + quantError * 7 / 16);
+                }
+                if (x - 1 >= 0 && y + 1 < height) {
+                    const idx = getPixelIndex(x - 1, y + 1);
+                    data[idx] = clamp(data[idx] + quantError * 3 / 16);
+                }
+                if (y + 1 < height) {
+                    const idx = getPixelIndex(x, y + 1);
+                    data[idx] = clamp(data[idx] + quantError * 5 / 16);
+                }
+                if (x + 1 < width && y + 1 < height) {
+                    const idx = getPixelIndex(x + 1, y + 1);
+                    data[idx] = clamp(data[idx] + quantError * 1 / 16);
+                }
+            }
+        }
+    },
+
+    /**
+     * Upscales image using nearest neighbor for chunky pixel effect
+     * @param {Uint8ClampedArray} smallData - Small image data
+     * @param {number} srcWidth - Small image width
+     * @param {number} srcHeight - Small image height
+     * @param {number} dstWidth - Target width
+     * @param {number} dstHeight - Target height
+     * @param {Uint8ClampedArray} originalData - Original data (for alpha channel)
+     * @returns {ImageData} Upscaled image data
+     */
+    _upscaleImage(smallData, srcWidth, srcHeight, dstWidth, dstHeight, originalData) {
+        const upscaledData = new Uint8ClampedArray(originalData);
+        const blockSize = this.currentBlockSize;
+        
+        for (let y = 0; y < dstHeight; y++) {
+            for (let x = 0; x < dstWidth; x++) {
+                // Map to small image coordinates based on block position
+                // Each block of pixels gets the same color for chunky effect
+                const blockX = Math.floor(x / blockSize);
+                const blockY = Math.floor(y / blockSize);
+                
+                // Map block coordinates to small image coordinates
+                const srcX = Math.min(blockX, srcWidth - 1);
+                const srcY = Math.min(blockY, srcHeight - 1);
+                
+                const srcIdx = (srcY * srcWidth + srcX) * 4;
+                const dstIdx = (y * dstWidth + x) * 4;
+                
+                upscaledData[dstIdx] = smallData[srcIdx];
+                upscaledData[dstIdx + 1] = smallData[srcIdx + 1];
+                upscaledData[dstIdx + 2] = smallData[srcIdx + 2];
+                // Keep original alpha channel
+                upscaledData[dstIdx + 3] = originalData[dstIdx + 3];
+            }
+        }
+        
+        return new ImageData(upscaledData, dstWidth, dstHeight);
+    }
+};
+
+// Legacy functions for backward compatibility
+function bayerDither(imageData, width) {
+    const data = new Uint8ClampedArray(imageData.data);
+    const height = imageData.data.length / 4 / width;
+    const blockSize = DitherManager.currentBlockSize;
+    
+    // 8x8 Bayer matrix normalized to 0-1 range
+    const bayerMatrix = [
+        [ 0, 48, 12, 60,  3, 51, 15, 63],
+        [32, 16, 44, 28, 35, 19, 47, 31],
+        [ 8, 56,  4, 52, 11, 59,  7, 55],
+        [40, 24, 36, 20, 43, 27, 39, 23],
+        [ 2, 50, 14, 62,  1, 49, 13, 61],
+        [34, 18, 46, 30, 33, 17, 45, 29],
+        [10, 58,  6, 54,  9, 57,  5, 53],
+        [42, 26, 38, 22, 41, 25, 37, 21]
+    ].map(row => row.map(x => x / 64));
+
+    // Process in blocks
+    for (let y = 0; y < height; y += blockSize) {
+        for (let x = 0; x < width; x += blockSize) {
+            // Calculate block average
+            let blockSum = [0, 0, 0];
+            let pixelCount = 0;
+            
+            for (let by = 0; by < blockSize && y + by < height; by++) {
+                for (let bx = 0; bx < blockSize && x + bx < width; bx++) {
+                    const idx = ((y + by) * width + (x + bx)) * 4;
+                    for (let c = 0; c < 3; c++) {
+                        blockSum[c] += data[idx + c];
+                    }
+                    pixelCount++;
+                }
+            }
+
+            // Calculate block average
+            const blockAvg = blockSum.map(sum => sum / pixelCount);
+            
+            // Get threshold from Bayer matrix for this block
+            const matrixX = Math.floor(x / blockSize) % 8;
+            const matrixY = Math.floor(y / blockSize) % 8;
+            const threshold = bayerMatrix[matrixY][matrixX];
+            
+            // Apply threshold to the block
+            for (let c = 0; c < 3; c++) {
+                const normalizedPixel = blockAvg[c] / 255;
+                const newPixel = normalizedPixel > threshold ? 255 : 0;
+                
+                // Fill the entire block with the new color
+                for (let by = 0; by < blockSize && y + by < height; by++) {
+                    for (let bx = 0; bx < blockSize && x + bx < width; bx++) {
+                        const idx = ((y + by) * width + (x + bx)) * 4;
+                        data[idx + c] = newPixel;
+                    }
+                }
+            }
+        }
+    }
+    
+    // Preserve alpha channel
+    for (let i = 3; i < data.length; i += 4) {
+        data[i] = imageData.data[i];
+    }
+    
+    return data;
+}
+
+function applyDithering(imageData, width) {
+    const method = document.getElementById('dither-select').value;
+    // For Pico Cam, get the current color tint if available
+    const colorTint = method === 'pico-cam' ? ColorManager.getCurrentColor() : null;
+    return DitherManager.applyDither(imageData, method, colorTint);
+}
+
+function floydSteinbergDither(imageData, width, blockSize) {
+    const { data } = imageData;
+    const height = imageData.data.length / 4 / width;
+
+    const newData = new Uint8ClampedArray(data);
+    const threshold = 128;
+    const levels = 4;
+
+    for (let y = 0; y < height; y++) {
+        for (let x = 0; x < width; x++) {
+            const idx = (y * width + x) * 4;
+            
+            for (let c = 0; c < 3; c++) {
+                const oldPixel = data[idx + c];
+                const quantizedPixel = DitherManager._quantize(oldPixel, levels);
+                const newPixel = quantizedPixel > threshold ? 255 : 0;
+                const error = oldPixel - newPixel;
+                
+                newData[idx + c] = newPixel;
+                
+                if (x + 1 < width) {
+                    newData[idx + 4 + c] += error * 7 / 16;
+                }
+                if (y + 1 === height) continue;
+                if (x > 0) {
+                    newData[idx + width * 4 - 4 + c] += error * 3 / 16;
+                }
+                newData[idx + width * 4 + c] += error * 5 / 16;
+                if (x + 1 < width) {
+                    newData[idx + width * 4 + 4 + c] += error * 1 / 16;
+                }
+            }
+        }
+    }
+
+    return new ImageData(newData, width, height);
+}
+
+function atkinsonDither(imageData, width, blockSize) {
+    const { data } = imageData;
+    const height = imageData.data.length / 4 / width;
+
+    const newData = new Uint8ClampedArray(data);
+    const threshold = 128;
+    const levels = 4;
+
+    for (let y = 0; y < height; y++) {
+        for (let x = 0; x < width; x++) {
+            const idx = (y * width + x) * 4;
+            
+            for (let c = 0; c < 3; c++) {
+                const oldPixel = data[idx + c];
+                const quantizedPixel = DitherManager._quantize(oldPixel, levels);
+                const newPixel = quantizedPixel > threshold ? 255 : 0;
+                const error = oldPixel - newPixel;
+                
+                newData[idx + c] = newPixel;
+                
+                if (x + 1 < width) {
+                    newData[idx + 4 + c] += error / 8;
+                }
+                if (x + 2 < width) {
+                    newData[idx + 8 + c] += error / 8;
+                }
+                if (y + 1 === height) continue;
+                if (x > 0) {
+                    newData[idx + width * 4 - 4 + c] += error / 8;
+                }
+                newData[idx + width * 4 + c] += error / 8;
+                if (x + 1 < width) {
+                    newData[idx + width * 4 + 4 + c] += error / 8;
+                }
+                if (y + 2 === height) continue;
+                if (x + 1 < width) {
+                    newData[idx + width * 8 + 4 + c] += error / 8;
+                }
+            }
+        }
+    }
+
+    return new ImageData(newData, width, height);
+}
+
+function orderedDither(imageData, width, blockSize) {
+    const { data } = imageData;
+    const height = imageData.data.length / 4 / width;
+    
+    const newData = new Uint8ClampedArray(data);
+    const matrix = [
+        [0, 8, 2, 10],
+        [12, 4, 14, 6],
+        [3, 11, 1, 9],
+        [15, 7, 13, 5]
+    ];
+    const matrixSize = 4;
+
+    for (let y = 0; y < height; y++) {
+        for (let x = 0; x < width; x++) {
+            const idx = (y * width + x) * 4;
+            const matrixX = x % matrixSize;
+            const matrixY = y % matrixSize;
+            const matrixThreshold = (matrix[matrixY][matrixX] / 16.0) * 255; // Normalize to 0-255
+
+            for (let c = 0; c < 3; c++) {
+                const pixel = data[idx + c];
+                // Use matrix threshold for comparison instead of adding to pixel value
+                newData[idx + c] = pixel > matrixThreshold ? 255 : 0;
+            }
+        }
+    }
+
+    return new ImageData(newData, width, height);
+} 
\ No newline at end of file
diff --git a/js/leibovitz/favicon-16x16.png b/js/leibovitz/favicon-16x16.png
new file mode 100644
index 0000000..9293108
--- /dev/null
+++ b/js/leibovitz/favicon-16x16.png
Binary files differdiff --git a/js/leibovitz/favicon-32x32.png b/js/leibovitz/favicon-32x32.png
new file mode 100644
index 0000000..b6b0694
--- /dev/null
+++ b/js/leibovitz/favicon-32x32.png
Binary files differdiff --git a/js/leibovitz/favicon-96x96.png b/js/leibovitz/favicon-96x96.png
new file mode 100644
index 0000000..a4fcd87
--- /dev/null
+++ b/js/leibovitz/favicon-96x96.png
Binary files differdiff --git a/js/leibovitz/favicon.ico b/js/leibovitz/favicon.ico
new file mode 100644
index 0000000..8ce4e6e
--- /dev/null
+++ b/js/leibovitz/favicon.ico
Binary files differdiff --git a/js/leibovitz/index.html b/js/leibovitz/index.html
new file mode 100644
index 0000000..0c38a58
--- /dev/null
+++ b/js/leibovitz/index.html
@@ -0,0 +1,710 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Leibovitz</title>
+    <meta name="description" content="Leibovitz is a web-based camera that lets you make fun photos.">
+    <link rel="apple-touch-icon" sizes="57x57" href="apple-icon-57x57.png">
+    <link rel="apple-touch-icon" sizes="60x60" href="apple-icon-60x60.png">
+    <link rel="apple-touch-icon" sizes="72x72" href="apple-icon-72x72.png">
+    <link rel="apple-touch-icon" sizes="76x76" href="apple-icon-76x76.png">
+    <link rel="apple-touch-icon" sizes="114x114" href="apple-icon-114x114.png">
+    <link rel="apple-touch-icon" sizes="120x120" href="apple-icon-120x120.png">
+    <link rel="apple-touch-icon" sizes="144x144" href="apple-icon-144x144.png">
+    <link rel="apple-touch-icon" sizes="152x152" href="apple-icon-152x152.png">
+    <link rel="apple-touch-icon" sizes="180x180" href="apple-icon-180x180.png">
+    <link rel="icon" type="image/png" sizes="192x192"  href="android-icon-192x192.png">
+    <link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
+    <link rel="icon" type="image/png" sizes="96x96" href="favicon-96x96.png">
+    <link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
+    <link rel="manifest" href="manifest.json">
+    <meta name="msapplication-TileColor" content="#ffffff">
+    <meta name="msapplication-TileImage" content="ms-icon-144x144.png">
+    <meta name="theme-color" content="#ffffff">
+    <style>
+        @font-face {
+            font-family: 'ChicagoFLF';
+            src: url('./ChicagoFLF.ttf') format('truetype');
+            font-weight: normal;
+            font-style: normal;
+        }
+        body, html {
+            margin: 0;
+            padding: 0;
+            width: 100%;
+            height: 100%;
+            display: flex;
+            flex-direction: column;
+            background-color: beige;
+            overflow: hidden;
+            font-family: 'ChicagoFLF', sans-serif;
+            font-size: 16px;
+        }
+        .preview-container {
+            flex: 1;
+            position: relative;
+            margin: 0;
+            min-height: 0;
+            padding-top: 28px;
+        }
+        #canvas {
+            width: 100%;
+            height: 100%;
+            object-fit: contain;
+            display: none;
+            background-color: transparent;
+            border-radius: 8px;
+            box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
+        }
+        .slide-controls {
+            position: absolute;
+            bottom: 20px;
+            left: 0;
+            right: 0;
+            display: none;
+            justify-content: space-around;
+            align-items: center;
+            background-color: rgba(255, 255, 255, 0.8);
+            padding: 10px;
+            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+            z-index: 10;
+            margin: 0 20px;
+        }
+        .slide-controls.visible {
+            display: flex;
+        }
+        .slider-group {
+            display: flex;
+            flex-direction: column;
+            align-items: center;
+            gap: 4px;
+            flex: 1;
+            max-width: 200px;
+        }
+        .slider-group label {
+            font-size: 12px;
+            color: #666;
+            font-family: 'ChicagoFLF', sans-serif;
+        }
+        .slider-group input[type="range"] {
+            width: 100%;
+            height: 4px;
+            background: rgba(0, 0, 0, 0.2);
+            border-radius: 2px;
+            outline: none;
+        }
+        
+        /* Only apply webkit-appearance: none on non-Android Chrome browsers */
+        .slider-group input[type="range"]:not(.android-chrome) {
+            -webkit-appearance: none;
+            appearance: none;
+        }
+        .slider-group input[type="range"]::-webkit-slider-runnable-track {
+            width: 100%;
+            height: 4px;
+            background: rgba(0, 0, 0, 0.2);
+            border-radius: 2px;
+            outline: none;
+        }
+        
+        /* Only apply webkit-appearance: none on non-Android Chrome browsers */
+        .slider-group input[type="range"]:not(.android-chrome)::-webkit-slider-runnable-track {
+            -webkit-appearance: none;
+        }
+        .slider-group input[type="range"]::-webkit-slider-thumb {
+            width: 16px;
+            height: 16px;
+            background: teal;
+            border-radius: 0;
+            cursor: pointer;
+        }
+        
+        /* Only apply webkit-appearance: none on non-Android Chrome browsers */
+        .slider-group input[type="range"]:not(.android-chrome)::-webkit-slider-thumb {
+            -webkit-appearance: none;
+        }
+        .slider-group input[type="range"]::-moz-range-thumb {
+            width: 16px;
+            height: 16px;
+            background: teal;
+            border-radius: 0;
+            cursor: pointer;
+            border: none;
+        }
+        .slider-group .value {
+            font-size: 12px;
+            color: #666;
+            font-family: 'ChicagoFLF', sans-serif;
+        }
+        .side-control {
+            position: absolute;
+            top: 50%;
+            height: 100%;
+            width: auto;
+            transform: translateY(-50%);
+            display: flex;
+            flex-direction: column;
+            align-items: center;
+            gap: 1em;
+            padding: 1em 0;
+            background-color: rgba(255, 255, 255, 0.8);
+            z-index: 10;
+        }
+        .side-control.left {
+            left: 0;
+        }
+        .side-control.right {
+            right: 0;
+        }
+
+        .vertical-slider {
+            transform: rotate(-90deg);
+            width: 200px;
+            margin: 90px -80px;
+            cursor: pointer;
+        }
+        input[type="range"]::-webkit-slider-thumb, .vertical-slider::-webkit-slider-thumb {
+            width: 20px;
+            height: 20px;
+            background: teal;
+            border-radius: 0;
+            cursor: pointer;
+        }
+        
+        /* Only apply webkit-appearance: none on non-Android Chrome browsers */
+        input[type="range"]:not(.android-chrome)::-webkit-slider-thumb, 
+        .vertical-slider:not(.android-chrome)::-webkit-slider-thumb {
+            -webkit-appearance: none;
+            appearance: none;
+        }
+        input[type="range"]::-webkit-slider-runnable-track, .vertical-slider::-webkit-slider-runnable-track {
+            width: 100%;
+            height: 4px;
+            background: rgba(255, 255, 255, 0.8);
+            border-radius: 2px;
+        }
+        input[type="range"]::-moz-range-thumb, .vertical-slider::-moz-range-thumb {
+            width: 20px;
+            height: 20px;
+            background: teal;
+            border-radius: 0;
+            cursor: pointer;
+            border: none;
+        }
+        input[type="range"]::-moz-range-track, .vertical-slider::-moz-range-track {
+            width: 100%;
+            height: 4px;
+            background: rgba(255, 255, 255, 0.8);
+            border-radius: 2px;
+        }
+        .vertical-label {
+            transform: rotate(90deg);
+            font-size: 12px;
+            color: #666;
+            user-select: none;
+            white-space: nowrap;
+            margin: 20px 0;
+            font-family: 'ChicagoFLF', sans-serif;
+            padding: 0 5px;
+        }
+        #blur-value {
+            font-size: 12px;
+            color: #666;
+            user-select: none;
+            white-space: nowrap;
+            font-family: 'ChicagoFLF', sans-serif;
+            padding: 0 5px;
+        }
+        #contrast-value {
+            font-size: 12px;
+            color: #666;
+            user-select: none;
+            white-space: nowrap;
+            font-family: 'ChicagoFLF', sans-serif;
+            padding: 0 5px;
+        }
+        #controls {
+            width: 100%;
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            gap: 0;
+            padding: 0;
+            background-color: rgba(255, 255, 255, 0.8);
+            box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1);
+            flex-shrink: 0;
+        }
+        #settings-container {
+            width: 100%;
+            display: none;
+            flex-direction: column;
+            gap: 10px;
+            align-items: center;
+            background-color: rgba(255, 255, 255, 0.8);
+            padding: 10px;
+            box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1);
+            flex-shrink: 0;
+            position: relative;
+        }
+        #settings-container.visible {
+            display: flex;
+        }
+        #offline-status {
+            position: fixed;
+            top: 0;
+            left: 0;
+            right: 0;
+            width: 100%;
+            font-size: 12px;
+            color: #666;
+            display: none;
+            font-family: 'ChicagoFLF', sans-serif;
+            background-color: rgba(255, 255, 255, 0.8);
+            text-align: center;
+            padding: 4px;
+            z-index: 1000;
+            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+        }
+        #offline-status.visible {
+            display: block;
+        }
+        .top-controls {
+            display: flex;
+            gap: 10px;
+            align-items: center;
+            width: 100%;
+            justify-content: center;
+            flex-wrap: nowrap;
+        }
+        .color-controls {
+            display: flex;
+            gap: 5px;
+            align-items: center;
+            flex-shrink: 0;
+        }
+        #reset-color {
+            padding: 8px;
+            font-size: 18px;
+            background: none;
+            border: 1px solid #ccc;
+            border-radius: 4px;
+            cursor: pointer;
+            transition: all 0.2s ease;
+            font-family: 'ChicagoFLF', sans-serif;
+            width: 40px;
+            height: 40px;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+        }
+        input[type="color"] {
+            width: 40px;
+            height: 40px;
+            padding: 0;
+            border: 1px solid #ccc;
+            border-radius: 4px;
+            cursor: pointer;
+        }
+        select {
+            padding: 10px 15px;
+            font-family: 'ChicagoFLF', sans-serif;
+            background-color: #f0f0f0;
+            border: 1px solid #ccc;
+            border-radius: 4px;
+            cursor: pointer;
+            font-size: 16px;
+            line-height: 1.2;
+            color: #333;
+            background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='none' stroke='%23333'%3E%3Cpath d='M4 6l4 4 4-4'/%3E%3C/svg%3E");
+            background-repeat: no-repeat;
+            background-position: right 10px center;
+            background-size: 16px;
+            padding-right: 35px;
+        }
+        
+        /* Only apply webkit-appearance: none on non-Android Chrome browsers */
+        select:not(.android-chrome) {
+            -webkit-appearance: none;
+            -moz-appearance: none;
+            appearance: none;
+        }
+        select:focus {
+            outline: none;
+            border-color: teal;
+            box-shadow: 0 0 0 2px rgba(0, 128, 128, 0.2);
+        }
+        #dither-select {
+            min-width: 200px;
+            max-width: none;
+            flex-shrink: 0;
+        }
+        button.capture {
+            background-color: teal;
+            color: #FFFFFF;
+            padding: 10px 20px;
+            font-family: 'ChicagoFLF', sans-serif;
+        }
+        #toggle-camera {
+            padding: 10px 20px;
+            font-family: 'ChicagoFLF', sans-serif;
+            border-right: 1px solid rgba(0, 0, 0, 0.1);
+        }
+        #toggle-camera.hidden {
+            display: none;
+        }
+        button, select, input[type="color"] {
+            padding: 10px;
+            font-size: 18px;
+            cursor: pointer;
+            font-family: 'ChicagoFLF', sans-serif;
+        }
+
+        button:hover, button:focus {
+            outline: none; 
+        }
+
+        button.capture:disabled {
+            background-color: #ccc;
+            color: #5c5c5c;
+        }
+
+        .contrast-control {
+            display: flex;
+            flex-direction: column;
+            align-items: center;
+            gap: 4px;
+            width: 100%;
+            max-width: 300px;
+            margin: 0 auto;
+            padding: 0 10px;
+        }
+        .contrast-control label {
+            font-size: 12px;
+            color: #666;
+            width: 100%;
+            text-align: center;
+            font-family: 'ChicagoFLF', sans-serif;
+        }
+        .contrast-control input[type="range"] {
+            width: 100%;
+        }
+        .contrast-control .slider-container {
+            display: flex;
+            align-items: center;
+            gap: 10px;
+            width: 100%;
+        }
+        .contrast-control .slider-container input[type="range"] {
+            flex: 1;
+        }
+        #block-size-value {
+            font-size: 12px;
+            color: #666;
+            min-width: 40px;
+            text-align: right;
+            font-family: 'ChicagoFLF', sans-serif;
+        }
+        #focus-container {
+            display: none;
+        }
+        #toggle-camera, button.capture, #edit-image {
+            font-size: 18px;
+            padding: 20px;
+            font-family: 'ChicagoFLF', sans-serif;
+            border-radius: 0;
+            text-align: center;
+            flex: 1;
+        }
+        button.capture:disabled {
+            background-color: #ccc;
+            color: #5c5c5c;
+        }
+
+        /* Performance Panel Styles */
+        .performance-panel {
+            position: absolute;
+            top: 20px;
+            right: 20px;
+            background: rgba(0, 0, 0, 0.8);
+            color: white;
+            border-radius: 8px;
+            padding: 0;
+            font-family: 'ChicagoFLF', sans-serif;
+            font-size: 12px;
+            z-index: 100;
+            min-width: 200px;
+            box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
+        }
+
+        .performance-header {
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            padding: 8px 12px;
+            border-bottom: 1px solid rgba(255, 255, 255, 0.2);
+            cursor: pointer;
+        }
+
+        .performance-header span {
+            font-weight: bold;
+        }
+
+        .toggle-performance {
+            background: none;
+            border: none;
+            color: white;
+            font-size: 16px;
+            cursor: pointer;
+            padding: 0;
+            width: 20px;
+            height: 20px;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+        }
+
+        .performance-content {
+            padding: 12px;
+            display: block;
+        }
+
+        .performance-content.collapsed {
+            display: none;
+        }
+
+        .performance-metric {
+            display: flex;
+            justify-content: space-between;
+            margin-bottom: 6px;
+        }
+
+        .performance-metric:last-child {
+            margin-bottom: 0;
+        }
+
+        .metric-label {
+            color: rgba(255, 255, 255, 0.8);
+        }
+
+        @media (max-width: 600px) {
+            #controls {
+                flex-direction: column;
+            }
+
+            #toggle-camera, button.capture, #edit-image {
+                width: 100%;
+                border-right: none;
+                border-bottom: 1px solid rgba(0, 0, 0, 0.1);
+            }
+
+            #toggle-camera:last-child, button.capture:last-child, #edit-image:last-child {
+                border-bottom: none;
+            }
+
+            .slide-controls {
+                flex-direction: column;
+                gap: 12px;
+                padding: 15px;
+            }
+            .slider-group {
+                width: 100%;
+                max-width: none;
+                flex-direction: row;
+                align-items: center;
+                gap: 12px;
+            }
+            .slider-group label {
+                min-width: 80px;
+                text-align: left;
+                font-size: 12px;
+                color: #666;
+            }
+            .slider-group input[type="range"] {
+                flex: 1;
+            }
+            .slider-group .value {
+                min-width: 40px;
+                text-align: right;
+                font-size: 12px;
+                color: #666;
+            }
+            select {
+                width: 100%;
+                max-width: 100%;
+            }
+            .top-controls {
+                flex-wrap: nowrap;
+                width: auto;
+                gap: 5px;
+            }
+            
+            #dither-select {
+                min-width: 150px;
+                max-width: none;
+            }
+
+            .color-controls {
+                flex-shrink: 0;
+            }
+        }
+        #edit-image {
+            display: block;
+            padding: 20px;
+            font-family: 'ChicagoFLF', sans-serif;
+            border-radius: 0;
+            text-align: center;
+            background-color: teal;
+            color: #FFFFFF;
+        }
+        #edit-image.hidden {
+            display: none;
+        }
+    </style>
+</head>
+<body>
+
+<div class="preview-container">
+    <canvas id="canvas"></canvas>
+    
+    <!-- Performance Monitoring Panel -->
+    <div id="performance-panel" class="performance-panel">
+        <div class="performance-header">
+            <span>Performance</span>
+            <button id="toggle-performance" class="toggle-performance">−</button>
+        </div>
+        <div class="performance-content">
+            <div class="performance-metric">
+                <span class="metric-label">FPS:</span>
+                <span id="fps-display">--</span>
+            </div>
+            <div class="performance-metric">
+                <span class="metric-label">Avg Frame:</span>
+                <span id="avg-frame-display">--</span>
+            </div>
+            <div class="performance-metric">
+                <span class="metric-label">Slowest:</span>
+                <span id="slowest-frame-display">--</span>
+            </div>
+            <div class="performance-metric">
+                <span class="metric-label">Total Frames:</span>
+                <span id="total-frames-display">--</span>
+            </div>
+        </div>
+    </div>
+    
+    <div class="slide-controls">
+        <div class="slider-group">
+            <label for="blur-slider">Blur</label>
+            <input type="range" id="blur-slider" min="0" max="20" value="0" step="1">
+            <span class="value" id="blur-value">0%</span>
+        </div>
+        <div class="slider-group">
+            <label for="contrast-slider">Contrast</label>
+            <input type="range" id="contrast-slider" min="-255" max="255" value="0" step="1">
+            <span class="value" id="contrast-value">0</span>
+        </div>
+        <div class="slider-group">
+            <label for="balance-slider">Balance</label>
+            <input type="range" id="balance-slider" min="2000" max="10000" value="6500" step="100">
+            <span class="value" id="balance-value">6500K</span>
+        </div>
+        <div class="slider-group" id="focus-control" style="display: none;">
+            <label for="focus-slider">Focus</label>
+            <input type="range" id="focus-slider" min="0" max="100" step="1" value="50" disabled>
+            <span class="value" id="focus-value">50%</span>
+        </div>
+        <div class="slider-group" id="pixel-size-control" style="display: none;">
+            <label for="block-size-slider">Pixel Size</label>
+            <input type="range" id="block-size-slider" min="1" max="12" value="4" step="1">
+            <span class="value" id="block-size-value">4px</span>
+        </div>
+    </div>
+</div>
+
+<div id="settings-container">
+    <div id="offline-status">Offline Mode</div>
+    <div class="top-controls">
+        <select id="dither-select">
+            <option value="none">No Dithering</option>
+            <option value="floyd-steinberg">Floyd-Steinberg</option>
+            <option value="ordered">Ordered</option>
+            <option value="atkinson">Atkinson</option>
+            <option value="bayer">Bayer</option>
+            <option value="pico-cam">Pico Cam</option>
+        </select>
+        <div class="color-controls">
+            <input type="color" id="color-tint" title="Color Tint">
+            <button id="reset-color" title="Reset Color Tint">↺</button>
+        </div>
+    </div>
+</div>
+
+<div id="controls">
+    <button id="toggle-camera">Camera On</button>
+    <button id="edit-image" class="edit-image">Upload Image</button>
+    <button id="capture" disabled class="capture">Capture Image</button>
+    <input type="file" id="image-input" accept="image/*" style="display: none;">
+</div>
+
+<script src="dither.js"></script>
+<script src="contrast.js"></script>
+<script src="color.js"></script>
+<script src="blur.js"></script>
+<script src="balance.js"></script>
+<script src="leibovitz.js"></script>
+<script>
+// Browser detection for Android Chrome slider fix
+function isAndroidChrome() {
+    const userAgent = navigator.userAgent;
+    return /Android/.test(userAgent) && /Chrome/.test(userAgent) && !/Edge/.test(userAgent);
+}
+
+// Apply Android Chrome class to form elements if needed
+if (isAndroidChrome()) {
+    document.addEventListener('DOMContentLoaded', () => {
+        // Apply class to sliders and selects
+        const formElements = document.querySelectorAll('input[type="range"], select');
+        formElements.forEach(element => {
+            element.classList.add('android-chrome');
+        });
+        
+        // Add explicit touch event listeners for sliders on Android Chrome
+        const sliders = document.querySelectorAll('input[type="range"]');
+        sliders.forEach(slider => {
+            slider.addEventListener('touchstart', function(e) {
+                this.focus();
+            });
+            
+            slider.addEventListener('touchmove', function(e) {
+                e.preventDefault();
+                const touch = e.touches[0];
+                const rect = this.getBoundingClientRect();
+                const percent = (touch.clientX - rect.left) / rect.width;
+                const value = this.min + (this.max - this.min) * Math.max(0, Math.min(1, percent));
+                this.value = value;
+                
+                // Trigger input event manually
+                const event = new Event('input', { bubbles: true });
+                this.dispatchEvent(event);
+            });
+        });
+    });
+}
+
+// Add offline status handling
+window.addEventListener('online', () => {
+    document.getElementById('offline-status').classList.remove('visible');
+});
+
+window.addEventListener('offline', () => {
+    document.getElementById('offline-status').classList.add('visible');
+});
+
+// Check initial online status
+if (!navigator.onLine) {
+    document.getElementById('offline-status').classList.add('visible');
+}
+</script>
+</body>
+</html>
diff --git a/js/leibovitz/leibovitz.js b/js/leibovitz/leibovitz.js
new file mode 100644
index 0000000..b2de99b
--- /dev/null
+++ b/js/leibovitz/leibovitz.js
@@ -0,0 +1,955 @@
+/**
+ * Start here.
+ * 
+ * Susan Sontag:
+ * > The camera makes everyone a tourist in other people's reality, 
+ * > and eventually in one's own.
+ * 
+ * Uses multiple design patterns for state management and applying effects:
+ * - Observer Pattern: state management and effect application across modules
+ * - State Pattern: mode management (camera/edit)
+ * - Factory Pattern: UI initialization and media device creation
+ * - Strategy Pattern: algorithm selection when applying an effect
+ * - Command Pattern: canvas operations and state reset
+ * - Chain of Responsibility: sequential effect application
+ * 
+ * 
+ * Separate manager modules for each effect:
+ * - ColorManager: HSL-based color manipulation
+ * - DitherManager: multiple dithering algorithms
+ * - ContrastManager: linear contrast adjustment
+ * - BlurManager: optimized box blur
+ * - BalanceManager: temperature-based color adjustment
+ * 
+ * 
+ */
+
+/**
+ * Global error boundary for the application
+ * Catches and logs errors, provides graceful degradation
+ */
+const ErrorBoundary = {
+    /**
+     * Wraps a function with error handling
+     * @param {Function} fn - Function to wrap
+     * @param {string} context - Context for error logging
+     * @param {Function} fallback - Fallback function if error occurs
+     * @returns {Function} Wrapped function with error handling
+     */
+    wrap(fn, context, fallback = null) {
+        return function(...args) {
+            try {
+                return fn.apply(this, args);
+            } catch (error) {
+                console.error(`Error in ${context}:`, error);
+                if (fallback) {
+                    try {
+                        return fallback.apply(this, args);
+                    } catch (fallbackError) {
+                        console.error(`Fallback also failed in ${context}:`, fallbackError);
+                    }
+                }
+                return null;
+            }
+        };
+    },
+
+    /**
+     * Handles critical errors that could crash the application
+     * @param {Error} error - The error that occurred
+     * @param {string} context - Where the error occurred
+     */
+    handleCriticalError(error, context) {
+        console.error(`Critical error in ${context}:`, error);
+        
+        // Try to recover gracefully
+        try {
+            if (cameraOn) {
+                stopCamera();
+            }
+            clearCanvas();
+            showErrorMessage('An error occurred. Please refresh the page.');
+        } catch (recoveryError) {
+            console.error('Recovery failed:', recoveryError);
+        }
+    }
+};
+
+/**
+ * Shows error message to user
+ * @param {string} message - Error message to display
+ */
+function showErrorMessage(message) {
+    // Create or update error message element
+    let errorElement = document.getElementById('error-message');
+    if (!errorElement) {
+        errorElement = document.createElement('div');
+        errorElement.id = 'error-message';
+        errorElement.style.cssText = `
+            position: fixed;
+            top: 20px;
+            right: 20px;
+            background: #ff4444;
+            color: white;
+            padding: 15px;
+            border-radius: 5px;
+            z-index: 1000;
+            max-width: 300px;
+            box-shadow: 0 4px 8px rgba(0,0,0,0.3);
+        `;
+        document.body.appendChild(errorElement);
+    }
+    
+    errorElement.textContent = message;
+    errorElement.style.display = 'block';
+    
+    // Auto-hide after 5 seconds
+    setTimeout(() => {
+        errorElement.style.display = 'none';
+    }, 5000);
+}
+
+const canvas = document.getElementById('canvas');
+const ctx = canvas.getContext('2d');
+
+// Optimize canvas context for performance
+ctx.imageSmoothingEnabled = false; // Disable smoothing for better performance
+ctx.imageSmoothingQuality = 'low'; // Use lowest quality if smoothing is needed
+ctx.globalCompositeOperation = 'source-over'; // Default composite operation
+ctx.globalAlpha = 1.0; // Default alpha
+ctx.lineWidth = 1; // Default line width
+ctx.lineCap = 'butt'; // Default line cap
+ctx.lineJoin = 'miter'; // Default line join
+ctx.miterLimit = 10; // Default miter limit
+ctx.shadowBlur = 0; // Disable shadows for performance
+ctx.shadowColor = 'transparent'; // Transparent shadow color
+ctx.shadowOffsetX = 0; // No shadow offset
+ctx.shadowOffsetY = 0; // No shadow offset
+const video = document.createElement('video');
+const toggleCameraButton = document.getElementById('toggle-camera');
+const captureButton = document.getElementById('capture');
+const editImageButton = document.getElementById('edit-image');
+const imageInput = document.getElementById('image-input');
+const focusControl = document.getElementById('focus-control');
+const focusSlider = document.getElementById('focus-slider');
+const focusValue = document.getElementById('focus-value');
+const slideControls = document.querySelector('.slide-controls');
+
+let cameraOn = false;
+let stream = null;
+let track = null;
+let isEditMode = false;
+let originalImage = null; // Store the original image for edit mode
+
+/**
+ * High-performance effect pipeline with single-pass processing and dirty flags
+ * Eliminates redundant canvas operations and memory allocations
+ * 
+ * FUTURE WebGL OPPORTUNITIES:
+ * - Blur: Could use separable Gaussian shader (2 passes vs current CPU implementation)
+ * - Color/Contrast: Trivial fragment shader operations with uniform parameters
+ * - Balance: Simple RGB channel multiplication in fragment shader
+ * - Ordered Dithering: Could use texture-based threshold matrices
+ * 
+ * FUTURE LOOKUP TABLE OPPORTUNITIES:
+ * - HSL conversion: Pre-calculated RGB→HSL and HSL→RGB lookup tables
+ * - Temperature conversion: Pre-calculated RGB multipliers for common temperatures
+ * - Dithering thresholds: Pre-calculated threshold matrices for ordered patterns
+ */
+const EffectPipeline = {
+    // Reusable buffers to eliminate memory allocations
+    _imageDataBuffer: null,
+    _tempDataBuffer: null,
+    _bufferWidth: 0,
+    _bufferHeight: 0,
+    
+    // Dirty flags to skip unchanged effects
+    _dirtyFlags: {
+        contrast: false,
+        colorTint: false,
+        blur: false,
+        balance: false,
+        dither: false
+    },
+    
+    // Cached effect parameters for dirty detection
+    _cachedParams: {
+        contrast: null,
+        colorTint: null,
+        blur: null,
+        balance: null,
+        dither: null
+    },
+
+    // Performance monitoring
+    _performanceMetrics: {
+        totalFrames: 0,
+        totalProcessingTime: 0,
+        averageProcessingTime: 0,
+        slowestFrame: 0,
+        fastestFrame: Infinity,
+        lastFrameTime: 0
+    },
+
+    /**
+     * Records performance metrics for effect processing
+     * @param {number} processingTime - Time taken to process effects in milliseconds
+     */
+    _recordPerformance(processingTime) {
+        const metrics = this._performanceMetrics;
+        metrics.totalFrames++;
+        metrics.totalProcessingTime += processingTime;
+        metrics.averageProcessingTime = metrics.totalProcessingTime / metrics.totalFrames;
+        metrics.slowestFrame = Math.max(metrics.slowestFrame, processingTime);
+        metrics.fastestFrame = Math.min(metrics.fastestFrame, processingTime);
+        metrics.lastFrameTime = processingTime;
+
+        // Log performance warnings for slow frames
+        if (processingTime > 16.67) { // 60fps threshold
+            console.warn(`Slow frame detected: ${processingTime.toFixed(2)}ms (target: 16.67ms)`);
+        }
+    },
+
+    /**
+     * Gets current performance metrics
+     * @returns {Object} Performance metrics object
+     */
+    getPerformanceMetrics() {
+        return { ...this._performanceMetrics };
+    },
+
+    /**
+     * Initializes or resizes buffers for the given canvas dimensions
+     * Reuses existing buffers when possible to minimize allocations
+     */
+    _ensureBuffers(width, height) {
+        if (this._bufferWidth !== width || this._bufferHeight !== height || !this._imageDataBuffer) {
+            // Only reallocate when dimensions change
+            this._bufferWidth = width;
+            this._bufferHeight = height;
+            this._imageDataBuffer = new ImageData(width, height);
+            this._tempDataBuffer = new Uint8ClampedArray(width * height * 4);
+        }
+    },
+
+    /**
+     * Updates cached parameters - called after processing effects
+     * This allows the observer-based dirty flags to work correctly
+     */
+    _updateCachedParams() {
+        this._cachedParams = {
+            contrast: ContrastManager.getCurrentContrast(),
+            colorTint: ColorManager.getCurrentColor(),
+            blur: BlurManager.getCurrentBlur(),
+            balance: BalanceManager.getCurrentBalance(),
+            dither: DitherManager.getCurrentMode()
+        };
+    },
+
+    /**
+     * Single-pass effect processing pipeline
+     * Processes all effects on a single ImageData object to eliminate redundant canvas operations
+     * 
+     * PERFORMANCE IMPACT:
+     * - Reduces 5 getImageData() calls to 1 (eliminates ~32MB of GPU→CPU transfers per frame)
+     * - Reduces 5 putImageData() calls to 1 (eliminates ~32MB of CPU→GPU transfers per frame)  
+     * - Total reduction: ~64MB of memory transfers per frame at 1920×1080
+     * - Expected performance improvement: 80-90% for typical use cases
+     */
+    applyAllEffects() {
+        const startTime = performance.now();
+        const width = canvas.width;
+        const height = canvas.height;
+        
+        // Update cached parameters to get current values
+        this._updateCachedParams();
+        
+        // Early exit if no effects are active
+        if (!this._hasActiveEffects()) {
+            return;
+        }
+
+        this._ensureBuffers(width, height);
+        
+        // Single getImageData call (was 5 separate calls before)
+        const sourceImageData = ctx.getImageData(0, 0, width, height);
+        
+        // Copy source data to our reusable buffer
+        this._imageDataBuffer.data.set(sourceImageData.data);
+        
+        // Apply effects in sequence on the same buffer (in-place processing)
+        // Note: We apply effects every frame when they're active, only clearing dirty flags when effects are disabled
+        
+        if (this._cachedParams.contrast && this._cachedParams.contrast !== 1.0) {
+            ContrastManager.applyContrastInPlace(this._imageDataBuffer, this._cachedParams.contrast);
+        } else if (this._dirtyFlags.contrast) {
+            // Effect was turned off, clear the flag
+            this._dirtyFlags.contrast = false;
+        }
+        
+        if (this._cachedParams.colorTint) {
+            ColorManager.applyTintInPlace(this._imageDataBuffer, this._cachedParams.colorTint);
+        } else if (this._dirtyFlags.colorTint) {
+            // Effect was turned off, clear the flag
+            this._dirtyFlags.colorTint = false;
+        }
+        
+        if (this._cachedParams.blur) {
+            // Note: Blur creates new ImageData, so we need to handle it specially
+            const blurredImageData = BlurManager.applyBlur(this._imageDataBuffer, this._cachedParams.blur);
+            this._imageDataBuffer.data.set(blurredImageData.data);
+        } else if (this._dirtyFlags.blur) {
+            // Effect was turned off, clear the flag
+            this._dirtyFlags.blur = false;
+        }
+        
+        if (this._cachedParams.balance && this._cachedParams.balance !== 6500) {
+            BalanceManager.applyBalanceInPlace(this._imageDataBuffer);
+        } else if (this._dirtyFlags.balance) {
+            // Effect was turned off, clear the flag
+            this._dirtyFlags.balance = false;
+        }
+        
+        if (this._cachedParams.dither && this._cachedParams.dither !== 'none') {
+            // Note: Dithering creates new ImageData, so we need to handle it specially  
+            // For Pico Cam, pass the color tint to use as primary color
+            const ditherMode = this._cachedParams.dither;
+            const colorTint = ditherMode === 'pico-cam' ? this._cachedParams.colorTint : null;
+            const ditheredImageData = DitherManager.applyDither(this._imageDataBuffer, ditherMode, colorTint);
+            this._imageDataBuffer.data.set(ditheredImageData.data);
+        } else if (this._dirtyFlags.dither) {
+            // Effect was turned off, clear the flag
+            this._dirtyFlags.dither = false;
+        }
+        
+        // Single putImageData call (was 5 separate calls before)
+        ctx.putImageData(this._imageDataBuffer, 0, 0);
+        
+        // Record performance metrics
+        const processingTime = performance.now() - startTime;
+        this._recordPerformance(processingTime);
+    },
+
+    /**
+     * Checks if any effects are currently active
+     */
+    _hasActiveEffects() {
+        return (this._cachedParams.contrast && this._cachedParams.contrast !== 1.0) ||
+               this._cachedParams.colorTint ||
+               this._cachedParams.blur ||
+               (this._cachedParams.balance && this._cachedParams.balance !== 6500) ||
+               (this._cachedParams.dither && this._cachedParams.dither !== 'none');
+    },
+
+    /**
+     * Checks if any effect parameters have changed since last processing
+     */
+    _hasAnyDirtyFlags() {
+        return Object.values(this._dirtyFlags).some(flag => flag);
+    },
+
+    /**
+     * Forces all effects to be marked as dirty (for initialization or major changes)
+     */
+    markAllDirty() {
+        Object.keys(this._dirtyFlags).forEach(key => {
+            this._dirtyFlags[key] = true;
+        });
+    },
+
+    /**
+     * Resets the pipeline state
+     */
+    reset() {
+        this.markAllDirty();
+        // Reset cached parameters
+        Object.keys(this._cachedParams).forEach(key => {
+            this._cachedParams[key] = null;
+        });
+    }
+};
+
+// Initialize managers
+ColorManager.init();
+DitherManager.init();
+ContrastManager.init();
+BlurManager.init();
+BalanceManager.init();
+
+// Initialize canvas double-tap/click capture
+setupCanvasCapture();
+
+// Initialize effect pipeline and connect to effect managers
+EffectPipeline.markAllDirty();
+
+// Connect effect managers to pipeline for granular dirty flag updates
+BlurManager.subscribe(() => {
+    EffectPipeline._dirtyFlags.blur = true;
+});
+
+ContrastManager.subscribe(() => {
+    EffectPipeline._dirtyFlags.contrast = true;
+});
+
+ColorManager.subscribe(() => {
+    EffectPipeline._dirtyFlags.colorTint = true;
+});
+
+BalanceManager.subscribe(() => {
+    EffectPipeline._dirtyFlags.balance = true;
+});
+
+DitherManager.subscribe(() => {
+    EffectPipeline._dirtyFlags.dither = true;
+});
+
+/**
+ * Updates visibility of controls based on camera/edit mode state
+ */
+function updateSliderControlsVisibility() {
+    const settingsContainer = document.getElementById('settings-container');
+    if (cameraOn || isEditMode) {
+        slideControls.classList.add('visible');
+        settingsContainer.classList.add('visible');
+    } else {
+        slideControls.classList.remove('visible');
+        settingsContainer.classList.remove('visible');
+    }
+}
+
+/**
+ * Updates canvas dimensions while maintaining aspect ratio
+ */
+function updateCanvasSize() {
+    const container = document.querySelector('.preview-container');
+    const containerWidth = container.clientWidth;
+    const containerHeight = container.clientHeight;
+    
+    if (video.videoWidth && video.videoHeight) {
+        const videoAspect = video.videoWidth / video.videoHeight;
+        const containerAspect = containerWidth / containerHeight;
+        
+        if (containerAspect > videoAspect) {
+            canvas.height = containerHeight;
+            canvas.width = containerHeight * videoAspect;
+        } else {
+            canvas.width = containerWidth;
+            canvas.height = containerWidth / videoAspect;
+        }
+    } else {
+        canvas.width = containerWidth;
+        canvas.height = containerHeight;
+    }
+}
+
+window.addEventListener('resize', () => {
+    if (cameraOn || isEditMode) {
+        updateCanvasSize();
+        if (isEditMode && originalImage) {
+            applyEffects();
+        }
+    }
+});
+
+updateCanvasSize();
+
+function clearCanvas() {
+    const container = document.querySelector('.preview-container');
+    const containerWidth = container.clientWidth;
+    const containerHeight = container.clientHeight;
+    
+    canvas.width = containerWidth;
+    canvas.height = containerHeight;
+    ctx.clearRect(0, 0, containerWidth, containerHeight);
+    ctx.setTransform(1, 0, 0, 1, 0, 0);
+    canvas.style.display = 'none';
+}
+
+/**
+ * Initializes camera access and sets up video stream
+ * Implements the Factory pattern for media device creation
+ * Uses the State pattern for mode management and UI state
+ */
+function startCamera() {
+    if (isEditMode) {
+        if (!confirm('Switching to camera mode will discard your current image and edits. Continue?')) {
+            return;
+        }
+        isEditMode = false;
+        originalImage = null;
+        clearCanvas();
+    }
+
+    navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' } } })
+        .then(s => {
+            stream = s;
+            video.srcObject = stream;
+            video.play();
+            canvas.style.display = 'block';
+            captureButton.disabled = false;
+            captureButton.active = true;
+            editImageButton.classList.add('hidden');
+            toggleCameraButton.classList.add('hidden');
+            isEditMode = false;
+            originalImage = null;
+            updateSliderControlsVisibility();
+
+            track = stream.getVideoTracks()[0];
+            const settings = track.getSettings();
+
+            // Feature detection for focus control
+            // Relatively untested because I don't have a device with focus control
+            if ('focusDistance' in settings) {
+                focusControl.style.display = 'flex';
+                focusSlider.disabled = false;
+                focusSlider.value = settings.focusDistance || focusSlider.min;
+                focusValue.textContent = `${focusSlider.value}%`;
+
+                focusSlider.addEventListener('input', () => {
+                    const value = focusSlider.value;
+                    focusValue.textContent = `${value}%`;
+                    track.applyConstraints({
+                        advanced: [{ focusDistance: value }]
+                    });
+                });
+            } else {
+                console.warn('Focus control is not supported on this device.');
+                focusControl.style.display = 'none';
+            }
+
+            // Animation loop using requestAnimationFrame with optimized effect pipeline
+            video.addEventListener('play', function() {
+                let frameCount = 0;
+                let lastFpsTime = performance.now();
+                
+                function step() {
+                    if (!cameraOn) return;
+                    
+                    const frameStart = performance.now();
+                    drawVideoProportional();
+                    
+                    // Use optimized single-pass effect pipeline
+                    // This replaces 5 separate effect functions with 1 optimized pipeline
+                    EffectPipeline.applyAllEffects();
+                    
+                    // FPS monitoring (every 60 frames)
+                    frameCount++;
+                    if (frameCount % 60 === 0) {
+                        const currentTime = performance.now();
+                        const fps = 60000 / (currentTime - lastFpsTime);
+                        lastFpsTime = currentTime;
+                        
+                        // Log performance warnings
+                        if (fps < 30) {
+                            console.warn(`Low FPS detected: ${fps.toFixed(1)} (target: 60)`);
+                        }
+                    }
+                    
+                    requestAnimationFrame(step);
+                }
+                requestAnimationFrame(step);
+            });
+        })
+        .catch(err => {
+            console.error('Error accessing camera: ', err);
+            toggleCameraButton.classList.remove('hidden');
+        });
+}
+
+/**
+ * Stops camera stream and resets UI state
+ */
+function stopCamera() {
+    if (stream) {
+        stream.getTracks().forEach(track => track.stop());
+        video.pause();
+        clearCanvas();
+        captureButton.disabled = true;
+        captureButton.active = false;
+        focusSlider.disabled = true;
+        focusControl.style.display = 'none';
+        stream = null;
+        editImageButton.classList.remove('hidden');
+        toggleCameraButton.classList.remove('hidden');
+        updateSliderControlsVisibility();
+    }
+}
+
+/**
+ * Loads and displays an image file
+ * Uses aspect ratio preservation strategy for responsive display
+ */
+function loadImage(file) {
+    const reader = new FileReader();
+    reader.onload = function(e) {
+        const img = new Image();
+        img.onload = function() {
+            clearCanvas();
+            
+            const container = document.querySelector('.preview-container');
+            const containerWidth = container.clientWidth;
+            const containerHeight = container.clientHeight;
+            
+            const imgAspect = img.width / img.height;
+            const containerAspect = containerWidth / containerHeight;
+            
+            let canvasWidth, canvasHeight;
+            
+            if (containerAspect > imgAspect) {
+                canvasHeight = containerHeight;
+                canvasWidth = containerHeight * imgAspect;
+            } else {
+                canvasWidth = containerWidth;
+                canvasHeight = containerWidth / imgAspect;
+            }
+            
+            canvas.width = canvasWidth;
+            canvas.height = canvasHeight;
+            originalImage = img;
+            
+            ctx.drawImage(img, 0, 0, canvasWidth, canvasHeight);
+            canvas.style.display = 'block';
+            captureButton.disabled = false;
+            captureButton.active = true;
+            updateSliderControlsVisibility();
+            
+            function step() {
+                if (!isEditMode) return;
+                applyEffects();
+                requestAnimationFrame(step);
+            }
+            requestAnimationFrame(step);
+        };
+        img.src = e.target.result;
+    };
+    reader.readAsDataURL(file);
+}
+
+/**
+ * Applies all effects to the original image using optimized pipeline
+ */
+const applyEffects = ErrorBoundary.wrap(function() {
+    ctx.clearRect(0, 0, canvas.width, canvas.height);
+    ctx.drawImage(originalImage, 0, 0, canvas.width, canvas.height);
+    
+    // Use optimized single-pass effect pipeline for edit mode
+    EffectPipeline.applyAllEffects();
+}, 'applyEffects', function() {
+    // Fallback: just draw original image without effects
+    ctx.clearRect(0, 0, canvas.width, canvas.height);
+    ctx.drawImage(originalImage, 0, 0, canvas.width, canvas.height);
+});
+
+/**
+ * Draws video feed maintaining aspect ratio
+ */
+const drawVideoProportional = ErrorBoundary.wrap(function() {
+    ctx.clearRect(0, 0, canvas.width, canvas.height);
+    
+    const videoAspectRatio = video.videoWidth / video.videoHeight;
+    const canvasAspectRatio = canvas.width / canvas.height;
+
+    let drawWidth, drawHeight;
+
+    if (canvasAspectRatio > videoAspectRatio) {
+        drawHeight = canvas.height;
+        drawWidth = videoAspectRatio * drawHeight;
+    } else {
+        drawWidth = canvas.width;
+        drawHeight = drawWidth / videoAspectRatio;
+    }
+
+    const offsetX = (canvas.width - drawWidth) / 2;
+    const offsetY = (canvas.height - drawHeight) / 2;
+    
+    ctx.drawImage(video, offsetX, offsetY, drawWidth, drawHeight);
+}, 'drawVideoProportional', function() {
+    // Fallback: clear canvas and show error
+    ctx.clearRect(0, 0, canvas.width, canvas.height);
+    ctx.fillStyle = '#ff0000';
+    ctx.font = '16px Arial';
+    ctx.fillText('Video Error', 10, 30);
+});
+
+// Individual effect functions removed - replaced by optimized EffectPipeline.applyAllEffects()
+// This eliminates redundant getImageData/putImageData calls that were causing major performance bottlenecks
+
+/**
+ * Performs image capture with current effects applied
+ * Extracted for reuse by both button click and canvas double-tap/click
+ */
+function performCapture() {
+    const captureCanvas = document.createElement('canvas');
+    const captureCtx = captureCanvas.getContext('2d');
+    
+    // Set capture canvas to same size as display canvas
+    captureCanvas.width = canvas.width;
+    captureCanvas.height = canvas.height;
+    
+    // Draw the canvas directly without any border
+    captureCtx.drawImage(canvas, 0, 0);
+    
+    const link = document.createElement('a');
+    const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+    link.download = `leibovitz-${timestamp}.png`;
+    link.href = captureCanvas.toDataURL('image/png');
+    link.click();
+}
+
+/**
+ * Sets up double-tap/click capture functionality on the canvas
+ * Handles both mouse double-click and touch double-tap events
+ */
+function setupCanvasCapture() {
+    let lastTouchTime = 0;
+    const doubleTapDelay = 300; // ms
+    
+    /**
+     * Checks if capture should be triggered based on current state and event target
+     */
+    function shouldTriggerCapture(event) {
+        return !captureButton.disabled && 
+               event.target === canvas && 
+               (cameraOn || isEditMode);
+    }
+    
+    // Handle mouse double-click
+    canvas.addEventListener('dblclick', (event) => {
+        event.preventDefault();
+        if (shouldTriggerCapture(event)) {
+            performCapture();
+        }
+    });
+    
+    // Handle touch double-tap
+    canvas.addEventListener('touchend', (event) => {
+        if (!shouldTriggerCapture(event)) return;
+        
+        const currentTime = new Date().getTime();
+        const timeSinceLastTouch = currentTime - lastTouchTime;
+        
+        if (timeSinceLastTouch < doubleTapDelay && timeSinceLastTouch > 0) {
+            event.preventDefault(); // Only prevent default when actually capturing
+            performCapture();
+        }
+        
+        lastTouchTime = currentTime;
+    });
+}
+
+/**
+ * Captures the current canvas state with effects
+ */
+captureButton.addEventListener('click', () => {
+    performCapture();
+});
+
+toggleCameraButton.addEventListener('click', () => {
+    cameraOn = !cameraOn;
+    if (cameraOn) {
+        startCamera();
+        toggleCameraButton.textContent = 'Camera Off';
+    } else {
+        stopCamera();
+        toggleCameraButton.textContent = 'Camera On';
+    }
+});
+
+editImageButton.addEventListener('click', () => {
+    if (!cameraOn) {
+        imageInput.click();
+    }
+});
+
+imageInput.addEventListener('change', (e) => {
+    if (e.target.files && e.target.files[0]) {
+        isEditMode = true;
+        loadImage(e.target.files[0]);
+    }
+});
+
+/**
+ * Service Worker registration for offline functionality
+ */
+if ('serviceWorker' in navigator) {
+    window.addEventListener('load', () => {
+        navigator.serviceWorker.register('/service-worker.js')
+        .then(registration => {
+            console.log('ServiceWorker registration successful with scope: ', registration.scope);
+        }, err => {
+            console.log('ServiceWorker registration failed: ', err);
+        });
+    });
+}
+
+ColorManager._setupEventListeners();
+
+function resetEffects() {
+    // Mark all effects as dirty to force re-processing
+    EffectPipeline.markAllDirty();
+    
+    if (isEditMode && originalImage) {
+        applyEffects();
+    }
+}
+
+/**
+ * Reset handlers for each effect manager
+ */
+BlurManager.reset = function() {
+    this._currentBlur = 0;
+    this._slider.value = 0;
+    this._value.textContent = '0%';
+    this._notifyObservers();
+    resetEffects();
+};
+
+ContrastManager.reset = function() {
+    this._currentContrast = 1.0;
+    this._slider.value = 0;
+    document.getElementById('contrast-value').textContent = '0';
+    this._notifyObservers();
+    resetEffects();
+};
+
+ColorManager.reset = function() {
+    this._currentColor = null;
+    this._colorInput.value = '#ffffff';
+    this._notifyObservers();
+    resetEffects();
+};
+
+BalanceManager.reset = function() {
+    this.balanceSlider.value = 6500;
+    this.balanceValue.textContent = '6500K';
+    resetEffects();
+};
+
+DitherManager.reset = function() {
+    this._currentMode = 'none';
+    this._modeSelect.value = 'none';
+    this._notifyObservers();
+    resetEffects();
+};
+
+/**
+ * Performance Panel Management
+ * Provides real-time performance monitoring and display
+ */
+const PerformancePanel = {
+    _isCollapsed: false,
+    _updateInterval: null,
+
+    /**
+     * Initializes the performance panel
+     */
+    init() {
+        this._setupEventListeners();
+        this._startUpdates();
+    },
+
+    /**
+     * Sets up event listeners for the performance panel
+     */
+    _setupEventListeners() {
+        const toggleButton = document.getElementById('toggle-performance');
+        const header = document.querySelector('.performance-header');
+        const content = document.querySelector('.performance-content');
+
+        if (toggleButton && header && content) {
+            toggleButton.addEventListener('click', (e) => {
+                e.stopPropagation();
+                this._toggleCollapse();
+            });
+
+            header.addEventListener('click', () => {
+                this._toggleCollapse();
+            });
+        }
+    },
+
+    /**
+     * Toggles the collapse state of the performance panel
+     */
+    _toggleCollapse() {
+        this._isCollapsed = !this._isCollapsed;
+        const content = document.querySelector('.performance-content');
+        const toggleButton = document.getElementById('toggle-performance');
+        
+        if (content) {
+            content.classList.toggle('collapsed', this._isCollapsed);
+        }
+        
+        if (toggleButton) {
+            toggleButton.textContent = this._isCollapsed ? '+' : '−';
+        }
+    },
+
+    /**
+     * Starts the performance update loop
+     */
+    _startUpdates() {
+        this._updateInterval = setInterval(() => {
+            this._updateDisplay();
+        }, 1000); // Update every second
+    },
+
+    /**
+     * Updates the performance display with current metrics
+     */
+    _updateDisplay() {
+        const metrics = EffectPipeline.getPerformanceMetrics();
+        
+        // Update FPS display
+        const fpsDisplay = document.getElementById('fps-display');
+        if (fpsDisplay && metrics.lastFrameTime > 0) {
+            const fps = Math.round(1000 / metrics.lastFrameTime);
+            fpsDisplay.textContent = `${fps}`;
+        }
+
+        // Update average frame time
+        const avgFrameDisplay = document.getElementById('avg-frame-display');
+        if (avgFrameDisplay) {
+            avgFrameDisplay.textContent = `${metrics.averageProcessingTime.toFixed(1)}ms`;
+        }
+
+        // Update slowest frame
+        const slowestFrameDisplay = document.getElementById('slowest-frame-display');
+        if (slowestFrameDisplay) {
+            slowestFrameDisplay.textContent = `${metrics.slowestFrame.toFixed(1)}ms`;
+        }
+
+        // Update total frames
+        const totalFramesDisplay = document.getElementById('total-frames-display');
+        if (totalFramesDisplay) {
+            totalFramesDisplay.textContent = metrics.totalFrames.toLocaleString();
+        }
+    },
+
+    /**
+     * Stops the performance update loop
+     */
+    destroy() {
+        if (this._updateInterval) {
+            clearInterval(this._updateInterval);
+            this._updateInterval = null;
+        }
+    }
+};
+
+// Initialize performance panel when DOM is ready (only if dev mode is enabled)
+document.addEventListener('DOMContentLoaded', () => {
+    // Check if dev mode is enabled via URL parameter
+    const urlParams = new URLSearchParams(window.location.search);
+    const isDevMode = urlParams.get('dev') === 'true' || urlParams.get('dev') === '1';
+    
+    if (isDevMode) {
+        PerformancePanel.init();
+    } else {
+        // Hide the performance panel by default
+        const panel = document.getElementById('performance-panel');
+        if (panel) {
+            panel.style.display = 'none';
+        }
+    }
+});
\ No newline at end of file
diff --git a/js/leibovitz/manifest.json b/js/leibovitz/manifest.json
new file mode 100644
index 0000000..1ddc0b2
--- /dev/null
+++ b/js/leibovitz/manifest.json
@@ -0,0 +1,71 @@
+{
+ "name": "Leibovitz",
+ "short_name": "Leibovitz",
+ "description": "A web-based camera that lets you make fun photos",
+ "start_url": ".",
+ "display": "standalone",
+ "background_color": "#f5f5dc",
+ "theme_color": "#f5f5dc",
+ "orientation": "portrait",
+ "icons": [
+  {
+   "src": "android-icon-36x36.png",
+   "sizes": "36x36",
+   "type": "image/png",
+   "density": "0.75"
+  },
+  {
+   "src": "android-icon-48x48.png",
+   "sizes": "48x48",
+   "type": "image/png",
+   "density": "1.0"
+  },
+  {
+   "src": "android-icon-72x72.png",
+   "sizes": "72x72",
+   "type": "image/png",
+   "density": "1.5"
+  },
+  {
+   "src": "android-icon-96x96.png",
+   "sizes": "96x96",
+   "type": "image/png",
+   "density": "2.0"
+  },
+  {
+   "src": "android-icon-144x144.png",
+   "sizes": "144x144",
+   "type": "image/png",
+   "density": "3.0"
+  },
+  {
+   "src": "android-icon-192x192.png",
+   "sizes": "192x192",
+   "type": "image/png",
+   "density": "4.0"
+  },
+  {
+   "src": "apple-icon-180x180.png",
+   "sizes": "180x180",
+   "type": "image/png"
+  }
+ ],
+ "categories": ["photo", "camera", "art"],
+ "prefer_related_applications": false,
+ "shortcuts": [
+  {
+   "name": "Take Photo",
+   "short_name": "Camera",
+   "description": "Open camera to take a photo",
+   "url": "?action=camera",
+   "icons": [{ "src": "android-icon-96x96.png", "sizes": "96x96" }]
+  },
+  {
+   "name": "Edit Photo",
+   "short_name": "Edit",
+   "description": "Open photo editor",
+   "url": "?action=edit",
+   "icons": [{ "src": "android-icon-96x96.png", "sizes": "96x96" }]
+  }
+ ]
+}
\ No newline at end of file
diff --git a/js/leibovitz/ms-icon-144x144.png b/js/leibovitz/ms-icon-144x144.png
new file mode 100644
index 0000000..ae37a7e
--- /dev/null
+++ b/js/leibovitz/ms-icon-144x144.png
Binary files differdiff --git a/js/leibovitz/ms-icon-150x150.png b/js/leibovitz/ms-icon-150x150.png
new file mode 100644
index 0000000..d9edbdb
--- /dev/null
+++ b/js/leibovitz/ms-icon-150x150.png
Binary files differdiff --git a/js/leibovitz/ms-icon-310x310.png b/js/leibovitz/ms-icon-310x310.png
new file mode 100644
index 0000000..9512221
--- /dev/null
+++ b/js/leibovitz/ms-icon-310x310.png
Binary files differdiff --git a/js/leibovitz/ms-icon-70x70.png b/js/leibovitz/ms-icon-70x70.png
new file mode 100644
index 0000000..45b1734
--- /dev/null
+++ b/js/leibovitz/ms-icon-70x70.png
Binary files differdiff --git a/js/leibovitz/service-worker.js b/js/leibovitz/service-worker.js
new file mode 100644
index 0000000..617209a
--- /dev/null
+++ b/js/leibovitz/service-worker.js
@@ -0,0 +1,89 @@
+const CACHE_NAME = 'leibovitz-cache-v2';
+const urlsToCache = [
+    '.',
+    'index.html',
+    'leibovitz.js',
+    'blur.js',
+    'contrast.js',
+    'color.js',
+    'balance.js',
+    'dither.js',
+    'service-worker.js',
+    'ChicagoFLF.ttf',
+    'manifest.json',
+    // Icons
+    'android-icon-192x192.png',
+    'android-icon-512x512.png',
+    'favicon.ico',
+    'favicon-16x16.png',
+    'favicon-32x32.png',
+    'favicon-96x96.png',
+    'apple-icon-57x57.png',
+    'apple-icon-60x60.png',
+    'apple-icon-72x72.png',
+    'apple-icon-76x76.png',
+    'apple-icon-114x114.png',
+    'apple-icon-120x120.png',
+    'apple-icon-144x144.png',
+    'apple-icon-152x152.png'
+];
+
+// Install event - cache all necessary files
+self.addEventListener('install', event => {
+    event.waitUntil(
+        caches.open(CACHE_NAME)
+            .then(cache => {
+                return cache.addAll(urlsToCache);
+            })
+    );
+});
+
+// Activate event - clean up old caches
+self.addEventListener('activate', event => {
+    event.waitUntil(
+        caches.keys().then(cacheNames => {
+            return Promise.all(
+                cacheNames.map(cacheName => {
+                    if (cacheName !== CACHE_NAME) {
+                        return caches.delete(cacheName);
+                    }
+                })
+            );
+        })
+    );
+});
+
+// Fetch event - serve from cache, fallback to network
+self.addEventListener('fetch', event => {
+    event.respondWith(
+        caches.match(event.request)
+            .then(response => {
+                // Return cached response if found
+                if (response) {
+                    return response;
+                }
+                
+                // Clone the request because it can only be used once
+                const fetchRequest = event.request.clone();
+                
+                // Try to fetch from network
+                return fetch(fetchRequest).then(response => {
+                    // Check if we received a valid response
+                    if (!response || response.status !== 200 || response.type !== 'basic') {
+                        return response;
+                    }
+                    
+                    // Clone the response because it can only be used once
+                    const responseToCache = response.clone();
+                    
+                    // Cache the fetched response
+                    caches.open(CACHE_NAME)
+                        .then(cache => {
+                            cache.put(event.request, responseToCache);
+                        });
+                    
+                    return response;
+                });
+            })
+    );
+});
\ No newline at end of file