about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorelioat <elioat@tilde.institute>2025-03-29 22:41:49 -0400
committerelioat <elioat@tilde.institute>2025-03-29 22:41:49 -0400
commit5eeed0ebe09bc27e0cb4c1e5d71d5fad6946fa50 (patch)
tree49a7fff32626f6611b1beed826bc3b77e06c278b
parent0a82d97f1c97439cdc778fe4daeec5bcef42bbda (diff)
downloadtour-5eeed0ebe09bc27e0cb4c1e5d71d5fad6946fa50.tar.gz
*
-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/browserconfig.xml2
-rw-r--r--js/leibovitz/color.js170
-rw-r--r--js/leibovitz/contrast.js55
-rw-r--r--js/leibovitz/dither.js178
-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.html171
-rw-r--r--js/leibovitz/leibovitz.js182
-rw-r--r--js/leibovitz/manifest.json41
-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.js43
33 files changed, 842 insertions, 0 deletions
diff --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/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..4e9ed1b
--- /dev/null
+++ b/js/leibovitz/color.js
@@ -0,0 +1,170 @@
+// Color tint management module
+// Uses the Observer pattern to notify the main camera module of color tint changes
+
+const ColorManager = {
+    // Private state
+    _currentColor: null,
+    _observers: new Set(),
+    _colorInput: null,
+
+    // Initialize the color manager
+    init() {
+        this._setupEventListeners();
+    },
+
+    // Private methods
+    _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', () => {
+            this._currentColor = null;
+            this._colorInput.value = '#000000'; // Reset to black
+            this._notifyObservers();
+        });
+    },
+
+    _notifyObservers() {
+        this._observers.forEach(observer => observer(this._currentColor));
+    },
+
+    // Public methods
+    subscribe(observer) {
+        this._observers.add(observer);
+        return () => this._observers.delete(observer);
+    },
+
+    getCurrentColor() {
+        return this._currentColor;
+    },
+
+    // Apply color tint to an image using a more sophisticated LUT approach
+    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 creates 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;
+    },
+
+    // Helper method to convert hex color to RGB
+    _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;
+    },
+
+    // Convert RGB to HSL
+    _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];
+    },
+
+    // Convert HSL to RGB
+    _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)];
+    },
+
+    // Blend two hue values (handles circular nature of hue)
+    _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
+    _smoothBlend(v1, v2, factor) {
+        // Apply a smooth easing function
+        const t = factor * factor * (3 - 2 * factor);
+        return v1 + (v2 - v1) * t;
+    }
+}; 
\ No newline at end of file
diff --git a/js/leibovitz/contrast.js b/js/leibovitz/contrast.js
new file mode 100644
index 0000000..391556a
--- /dev/null
+++ b/js/leibovitz/contrast.js
@@ -0,0 +1,55 @@
+// Contrast management module
+// Uses the Observer pattern to notify the main camera module of contrast changes
+
+const ContrastManager = {
+    // Private state
+    _currentContrast: 1.0, // Default contrast (no change)
+    _observers: new Set(),
+    _slider: null,
+
+    // Initialize the contrast manager
+    init() {
+        this._setupEventListeners();
+    },
+
+    // Private methods
+    _setupEventListeners() {
+        this._slider = document.getElementById('contrast-slider');
+        this._slider.addEventListener('input', (e) => {
+            this._currentContrast = parseFloat(e.target.value);
+            this._notifyObservers();
+        });
+    },
+
+    _notifyObservers() {
+        this._observers.forEach(observer => observer(this._currentContrast));
+    },
+
+    // Public methods
+    subscribe(observer) {
+        this._observers.add(observer);
+        return () => this._observers.delete(observer);
+    },
+
+    getCurrentContrast() {
+        return this._currentContrast;
+    },
+
+    // Apply contrast to an image
+    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;
+    }
+}; 
\ No newline at end of file
diff --git a/js/leibovitz/dither.js b/js/leibovitz/dither.js
new file mode 100644
index 0000000..2a02834
--- /dev/null
+++ b/js/leibovitz/dither.js
@@ -0,0 +1,178 @@
+// Dithering management module
+// Uses the Observer pattern to notify the main camera module of dithering changes
+
+const DitherManager = {
+    // Private state
+    _currentMode: 'none',
+    _observers: new Set(),
+    _modeSelect: null,
+
+    // Initialize the dither manager
+    init() {
+        this._setupEventListeners();
+    },
+
+    // Private methods
+    _setupEventListeners() {
+        this._modeSelect = document.getElementById('dither-select');
+        this._modeSelect.addEventListener('change', (e) => {
+            this._currentMode = e.target.value;
+            this._notifyObservers();
+        });
+    },
+
+    _notifyObservers() {
+        this._observers.forEach(observer => observer(this._currentMode));
+    },
+
+    // Public methods
+    subscribe(observer) {
+        this._observers.add(observer);
+        return () => this._observers.delete(observer);
+    },
+
+    getCurrentMode() {
+        return this._currentMode;
+    },
+
+    // Apply dithering to an image
+    applyDither(imageData, mode) {
+        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);
+            default:
+                return imageData;
+        }
+    },
+
+    // Quantize a value to create chunkier output
+    _quantize(value, levels = 4) {
+        const step = 255 / (levels - 1);
+        return Math.round(value / step) * step;
+    },
+
+    // Floyd-Steinberg dithering
+    _floydSteinbergDither(data, width, height) {
+        const newData = new Uint8ClampedArray(data);
+        const threshold = 128;
+        const levels = 4; // Number of quantization levels
+
+        for (let y = 0; y < height; y++) {
+            for (let x = 0; x < width; x++) {
+                const idx = (y * width + x) * 4;
+                
+                // Process each color channel
+                for (let c = 0; c < 3; c++) {
+                    const oldPixel = newData[idx + c];
+                    const quantizedPixel = this._quantize(oldPixel, levels);
+                    const newPixel = quantizedPixel > threshold ? 255 : 0;
+                    const error = oldPixel - newPixel;
+                    
+                    newData[idx + c] = newPixel;
+                    
+                    // Distribute error to neighboring pixels
+                    if (x + 1 < width) {
+                        newData[idx + 4 + c] += error * 7 / 16; // right
+                    }
+                    if (y + 1 === height) continue;
+                    if (x > 0) {
+                        newData[idx + width * 4 - 4 + c] += error * 3 / 16; // bottom left
+                    }
+                    newData[idx + width * 4 + c] += error * 5 / 16; // bottom
+                    if (x + 1 < width) {
+                        newData[idx + width * 4 + 4 + c] += error * 1 / 16; // bottom right
+                    }
+                }
+            }
+        }
+
+        return new ImageData(newData, width, height);
+    },
+
+    // Ordered dithering (Bayer matrix)
+    _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 threshold = 128;
+        const levels = 4; // Number of quantization levels
+
+        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 matrixValue = matrix[matrixY][matrixX] * 16;
+
+                // Process each color channel
+                for (let c = 0; c < 3; c++) {
+                    const pixel = newData[idx + c];
+                    const quantizedPixel = this._quantize(pixel, levels);
+                    newData[idx + c] = quantizedPixel + matrixValue > threshold ? 255 : 0;
+                }
+            }
+        }
+
+        return new ImageData(newData, width, height);
+    },
+
+    // Atkinson dithering
+    _atkinsonDither(data, width, height) {
+        const newData = new Uint8ClampedArray(data);
+        const threshold = 128;
+        const levels = 4; // Number of quantization levels
+
+        for (let y = 0; y < height; y++) {
+            for (let x = 0; x < width; x++) {
+                const idx = (y * width + x) * 4;
+                
+                // Process each color channel
+                for (let c = 0; c < 3; c++) {
+                    const oldPixel = newData[idx + c];
+                    const quantizedPixel = this._quantize(oldPixel, levels);
+                    const newPixel = quantizedPixel > threshold ? 255 : 0;
+                    const error = oldPixel - newPixel;
+                    
+                    newData[idx + c] = newPixel;
+                    
+                    // Distribute error to neighboring pixels (Atkinson's algorithm)
+                    if (x + 1 < width) {
+                        newData[idx + 4 + c] += error / 8; // right
+                    }
+                    if (x + 2 < width) {
+                        newData[idx + 8 + c] += error / 8; // right + 1
+                    }
+                    if (y + 1 === height) continue;
+                    if (x > 0) {
+                        newData[idx + width * 4 - 4 + c] += error / 8; // bottom left
+                    }
+                    newData[idx + width * 4 + c] += error / 8; // bottom
+                    if (x + 1 < width) {
+                        newData[idx + width * 4 + 4 + c] += error / 8; // bottom right
+                    }
+                    if (y + 2 === height) continue;
+                    if (x + 1 < width) {
+                        newData[idx + width * 8 + 4 + c] += error / 8; // bottom + 1 right
+                    }
+                }
+            }
+        }
+
+        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..8af8f4f
--- /dev/null
+++ b/js/leibovitz/index.html
@@ -0,0 +1,171 @@
+<!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 capture photos with custom color tints.">
+    <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>
+        body, html {
+            margin: 0;
+            padding: 0;
+            width: 100%;
+            height: 100%;
+            display: flex;
+            justify-content: center;
+            align-items: center;
+            background-color: beige;
+        }
+        #canvas {
+            width: 70%;
+            height: auto;
+            max-height: 70vh;
+            object-fit: contain;
+            display: none;
+            background-color: #000;
+            border-radius: 8px;
+            box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
+        }
+        #controls {
+            position: absolute;
+            bottom: 20px;
+            width: 100%;
+            display: flex;
+            justify-content: center;
+            align-items: center;
+            gap: 10px;
+        }
+        #settings-container {
+            position: absolute;
+            top: 20px;
+            right: 20px;
+            display: flex;
+            flex-direction: column;
+            gap: 10px;
+            align-items: center;
+            background-color: rgba(255, 255, 255, 0.8);
+            padding: 10px;
+            border-radius: 8px;
+            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+        }
+        .top-controls {
+            display: flex;
+            gap: 10px;
+            align-items: center;
+        }
+        .color-controls {
+            display: flex;
+            gap: 5px;
+            align-items: center;
+        }
+        #reset-color {
+            padding: 5px 10px;
+            font-size: 16px;
+            background: none;
+            border: 1px solid #ccc;
+            border-radius: 4px;
+            cursor: pointer;
+            transition: all 0.2s ease;
+        }
+        #reset-color:hover {
+            background: #f0f0f0;
+        }
+        button, select, input[type="color"] {
+            padding: 10px;
+            font-size: 18px;
+            cursor: pointer;
+        }
+
+        button, select {
+            border: none;
+            cursor: pointer;
+            transition: background-color 0.3s ease;
+        }
+
+        button:hover, button:focus {
+            outline: none; 
+        }
+
+        button.capture {
+            background-color: teal;
+            color: #FFFFFF;
+        }
+
+        button.capture:disabled {
+            background-color: #ccc;
+            color: #5c5c5c;
+        }
+
+        .contrast-control {
+            display: flex;
+            flex-direction: column;
+            align-items: center;
+            gap: 4px;
+            width: 100%;
+        }
+        .contrast-control label {
+            font-size: 12px;
+            color: #666;
+            width: 100%;
+            text-align: center;
+        }
+        .contrast-control input[type="range"] {
+            width: 100%;
+        }
+
+    </style>
+</head>
+<body>
+
+<canvas id="canvas"></canvas>
+
+<div id="settings-container">
+    <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>
+        </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 class="contrast-control">
+        <label for="contrast-slider">Contrast</label>
+        <input type="range" id="contrast-slider" min="-255" max="255" value="0" step="1">
+    </div>
+</div>
+
+<div id="controls">
+    <div id="focus-container">
+        <input style="display: none;" type="range" id="focus-slider" min="0" max="100" step="1" value="50" disabled>
+    </div>
+    <button id="toggle-camera">Turn Camera On</button>
+    <button id="capture" disabled class="capture">Capture Image</button>
+</div>
+
+<script src="dither.js"></script>
+<script src="contrast.js"></script>
+<script src="color.js"></script>
+<script src="leibovitz.js"></script>
+</body>
+</html>
diff --git a/js/leibovitz/leibovitz.js b/js/leibovitz/leibovitz.js
new file mode 100644
index 0000000..01ce5ae
--- /dev/null
+++ b/js/leibovitz/leibovitz.js
@@ -0,0 +1,182 @@
+const canvas = document.getElementById('canvas');
+const ctx = canvas.getContext('2d');
+const video = document.createElement('video');
+const toggleCameraButton = document.getElementById('toggle-camera');
+const captureButton = document.getElementById('capture');
+
+let cameraOn = false;
+let stream = null;
+
+// Initialize managers
+ColorManager.init();
+DitherManager.init();
+ContrastManager.init();
+
+// Set the canvas dimensions to match the window size
+function updateCanvasSize() {
+    const containerWidth = window.innerWidth * 0.7; // 70% of viewport width
+    
+    // If video is playing, use its aspect ratio
+    if (video.videoWidth && video.videoHeight) {
+        const videoAspect = video.videoWidth / video.videoHeight;
+        canvas.width = containerWidth;
+        canvas.height = containerWidth / videoAspect;
+    } else {
+        // Default to container width and 4:3 aspect ratio until video starts
+        canvas.width = containerWidth;
+        canvas.height = containerWidth * (3/4);
+    }
+}
+
+// Update canvas size when window is resized
+window.addEventListener('resize', updateCanvasSize);
+
+// Initialize canvas size
+updateCanvasSize();
+
+const focusSlider = document.getElementById('focus-slider');
+let track = null;
+
+function startCamera() {
+    navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' } } })
+        .then(s => {
+            stream = s;
+            video.srcObject = stream;
+            video.play();
+            canvas.style.display = 'block'; // Show the canvas
+            captureButton.disabled = false;
+            captureButton.active = true;
+            focusSlider.disabled = false;
+
+            track = stream.getVideoTracks()[0];
+            const settings = track.getSettings();
+
+            // Check if focus control is supported with this browser and device combo
+            if ('focusDistance' in settings) {
+                focusSlider.style.display = 'block';
+                focusSlider.value = settings.focusDistance || focusSlider.min;
+
+                focusSlider.addEventListener('input', () => {
+                    track.applyConstraints({
+                        advanced: [{ focusDistance: focusSlider.value }]
+                    });
+                });
+            } else {
+                console.warn('Focus control is not supported on this device.');
+                focusSlider.style.display = 'none';
+            }
+
+            // Draw the video feed to the canvas
+            video.addEventListener('play', function() {
+                function step() {
+                    if (!cameraOn) return;
+                    drawVideoProportional();
+                    applyContrast();
+                    applyColorTint();
+                    applyDither();
+                    requestAnimationFrame(step);
+                }
+                requestAnimationFrame(step);
+            });
+        })
+        .catch(err => {
+            console.error('Error accessing camera: ', err);
+        });
+}
+
+function stopCamera() {
+    if (stream) {
+        stream.getTracks().forEach(track => track.stop());
+        video.pause();
+        canvas.style.display = 'none'; // hide the canvas
+        captureButton.disabled = true;
+        captureButton.active = false;
+        focusSlider.disabled = true;
+        stream = null;
+    }
+}
+
+function drawVideoProportional() {
+    // Clear the entire canvas
+    ctx.fillStyle = '#000';
+    ctx.fillRect(0, 0, canvas.width, canvas.height);
+    
+    // Draw the video content
+    ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
+}
+
+function applyColorTint() {
+    const currentColor = ColorManager.getCurrentColor();
+    if (!currentColor) return;
+
+    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+    const tintedImageData = ColorManager.applyTint(imageData, currentColor);
+    ctx.putImageData(tintedImageData, 0, 0);
+}
+
+function applyContrast() {
+    const currentContrast = ContrastManager.getCurrentContrast();
+    if (!currentContrast || currentContrast === 1.0) return;
+
+    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+    const contrastedImageData = ContrastManager.applyContrast(imageData, currentContrast);
+    ctx.putImageData(contrastedImageData, 0, 0);
+}
+
+function applyDither() {
+    const currentMode = DitherManager.getCurrentMode();
+    if (!currentMode || currentMode === 'none') return;
+
+    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+    const ditheredImageData = DitherManager.applyDither(imageData, currentMode);
+    ctx.putImageData(ditheredImageData, 0, 0);
+}
+
+captureButton.addEventListener('click', () => {
+    const currentColor = ColorManager.getCurrentColor();
+    const borderWidth = 4;
+    
+    // Create a canvas with extra space for the border
+    const captureCanvas = document.createElement('canvas');
+    const captureCtx = captureCanvas.getContext('2d');
+    
+    // Set dimensions including border
+    captureCanvas.width = canvas.width + (borderWidth * 2);
+    captureCanvas.height = canvas.height + (borderWidth * 2);
+    
+    // Fill with border color if a tint is selected
+    if (currentColor) {
+        captureCtx.fillStyle = currentColor;
+        captureCtx.fillRect(0, 0, captureCanvas.width, captureCanvas.height);
+    }
+    
+    // Draw the main canvas content
+    captureCtx.drawImage(canvas, borderWidth, borderWidth);
+    
+    const link = document.createElement('a');
+    link.download = 'captured-image.png';
+    link.href = captureCanvas.toDataURL('image/png');
+    link.click();
+});
+
+toggleCameraButton.addEventListener('click', () => {
+    cameraOn = !cameraOn;
+    if (cameraOn) {
+        startCamera();
+        toggleCameraButton.textContent = 'Turn Camera Off';
+    } else {
+        stopCamera();
+        toggleCameraButton.textContent = 'Turn Camera On';
+    }
+});
+
+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);
+        });
+    });
+}
\ No newline at end of file
diff --git a/js/leibovitz/manifest.json b/js/leibovitz/manifest.json
new file mode 100644
index 0000000..4b4a2c4
--- /dev/null
+++ b/js/leibovitz/manifest.json
@@ -0,0 +1,41 @@
+{
+ "name": "Leibovitz",
+ "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"
+  }
+ ]
+}
\ 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..3ed58c1
--- /dev/null
+++ b/js/leibovitz/service-worker.js
@@ -0,0 +1,43 @@
+const CACHE_NAME = 'lut-cam-cache-v1';
+const urlsToCache = [
+    '/',
+    './',
+    'index.html',
+    'lut.js',
+    'service-worker.js',
+    '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'
+];
+
+self.addEventListener('install', event => {
+  event.waitUntil(
+    caches.open(CACHE_NAME)
+      .then(cache => {
+        return cache.addAll(urlsToCache);
+      })
+  );
+});
+
+self.addEventListener('fetch', event => {
+  event.respondWith(
+    caches.match(event.request)
+      .then(response => {
+        if (response) {
+          return response;
+        }
+        return fetch(event.request);
+      })
+  );
+});
\ No newline at end of file