Last active
October 18, 2025 00:13
-
-
Save twobob/6499fe27243cef72103ec85e893cf63e to your computer and use it in GitHub Desktop.
do chroma for cheapa
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| export interface ChromaKeyOptions { | |
| keyColor?: [number, number, number] // RGB color to key out (default: magenta) | |
| tolerance?: number // Tolerance range for color matching (0-100) | |
| softness?: number // Edge softness (0-100) | |
| spill?: number // Spill suppression strength (0-100) | |
| preserveEdges?: boolean // Preserve edge detail | |
| } | |
| export interface ImageData { | |
| data: Uint8ClampedArray | |
| width: number | |
| height: number | |
| } | |
| export function detectMagentaColor(imageData: ImageData): [number, number, number] { | |
| const { data } = imageData | |
| // Look for pixels that are highly magenta (high R+B, low G) | |
| const magentaPixels: [number, number, number][] = [] | |
| for (let i = 0; i < data.length; i += 4) { | |
| const r = data[i] | |
| const g = data[i + 1] | |
| const b = data[i + 2] | |
| // Check if pixel is magenta-like (R+B > G, and reasonably bright) | |
| const magentaness = (r + b) - g | |
| const brightness = (r + g + b) / 3 | |
| if (magentaness > 50 && brightness > 50) { | |
| magentaPixels.push([r, g, b]) | |
| } | |
| } | |
| if (magentaPixels.length === 0) { | |
| // Default to pure magenta if no magenta pixels found | |
| return [255, 0, 255] | |
| } | |
| // Calculate average of magenta pixels | |
| const sum = magentaPixels.reduce( | |
| (acc, pixel) => [acc[0] + pixel[0], acc[1] + pixel[1], acc[2] + pixel[2]], | |
| [0, 0, 0] | |
| ) | |
| return [ | |
| Math.round(sum[0] / magentaPixels.length), | |
| Math.round(sum[1] / magentaPixels.length), | |
| Math.round(sum[2] / magentaPixels.length) | |
| ] | |
| } | |
| function colorDistance(c1: [number, number, number], c2: [number, number, number]): number { | |
| // Euclidean distance in RGB space | |
| const dr = c1[0] - c2[0] | |
| const dg = c1[1] - c2[1] | |
| const db = c1[2] - c2[2] | |
| return Math.sqrt(dr * dr + dg * dg + db * db) | |
| } | |
| function colorDistanceHSV(c1: [number, number, number], c2: [number, number, number]): number { | |
| // Convert RGB to HSV for better color matching | |
| const hsv1 = rgbToHsv(c1) | |
| const hsv2 = rgbToHsv(c2) | |
| // Weight hue difference more heavily for chroma keying | |
| const dh = Math.min(Math.abs(hsv1[0] - hsv2[0]), 360 - Math.abs(hsv1[0] - hsv2[0])) | |
| const ds = Math.abs(hsv1[1] - hsv2[1]) | |
| const dv = Math.abs(hsv1[2] - hsv2[2]) | |
| return Math.sqrt(dh * dh * 4 + ds * ds + dv * dv) | |
| } | |
| function rgbToHsv(rgb: [number, number, number]): [number, number, number] { | |
| const r = rgb[0] / 255 | |
| const g = rgb[1] / 255 | |
| const b = rgb[2] / 255 | |
| const max = Math.max(r, g, b) | |
| const min = Math.min(r, g, b) | |
| const diff = max - min | |
| let h = 0 | |
| if (diff !== 0) { | |
| if (max === r) h = 60 * (((g - b) / diff) % 6) | |
| else if (max === g) h = 60 * ((b - r) / diff + 2) | |
| else h = 60 * ((r - g) / diff + 4) | |
| } | |
| if (h < 0) h += 360 | |
| const s = max === 0 ? 0 : diff / max | |
| const v = max | |
| return [h, s * 100, v * 100] | |
| } | |
| export function chromaKeyPixelData( | |
| imageData: ImageData, | |
| options: ChromaKeyOptions = {} | |
| ): ImageData { | |
| const { | |
| keyColor = [255, 0, 255], // Default magenta | |
| tolerance = 20, | |
| softness = 5, | |
| spill = 30, | |
| preserveEdges = true | |
| } = options | |
| // Create new pixel data array with alpha channel | |
| const d = new Uint8ClampedArray(imageData.data) | |
| const toleranceRange = (tolerance / 100) * 255 | |
| const softnessRange = (softness / 100) * 255 | |
| const spillStrength = spill / 100 | |
| for (let i = 0; i < d.length; i += 4) { | |
| const pixel: [number, number, number] = [d[i], d[i + 1], d[i + 2]] | |
| // Calculate distance to key color using HSV-based matching | |
| const distance = colorDistanceHSV(pixel, keyColor) | |
| const rgbDistance = colorDistance(pixel, keyColor) | |
| // Use the better of the two distance measures | |
| const finalDistance = Math.min(distance * 2, rgbDistance) | |
| if (finalDistance <= toleranceRange) { | |
| // Pixel is within key color range - make transparent | |
| if (finalDistance <= toleranceRange - softnessRange) { | |
| // Fully transparent | |
| d[i + 3] = 0 | |
| } else { | |
| // Soft edge - partial transparency | |
| const alpha = (finalDistance - (toleranceRange - softnessRange)) / softnessRange | |
| d[i + 3] = Math.round(alpha * 255) | |
| } | |
| } else if (spillStrength > 0) { | |
| // Apply spill suppression to nearby colors | |
| const spillDistance = finalDistance - toleranceRange | |
| const spillRange = toleranceRange * 2 | |
| if (spillDistance < spillRange) { | |
| const spillFactor = 1 - (spillDistance / spillRange) | |
| const spillAmount = spillFactor * spillStrength | |
| // Reduce the key color components | |
| if (keyColor[0] > keyColor[1] && keyColor[0] > keyColor[2]) { | |
| // Key color is red-dominant | |
| d[i] = Math.round(d[i] * (1 - spillAmount * 0.5)) | |
| } | |
| if (keyColor[1] > keyColor[0] && keyColor[1] > keyColor[2]) { | |
| // Key color is green-dominant | |
| d[i + 1] = Math.round(d[i + 1] * (1 - spillAmount * 0.5)) | |
| } | |
| if (keyColor[2] > keyColor[0] && keyColor[2] > keyColor[1]) { | |
| // Key color is blue-dominant | |
| d[i + 2] = Math.round(d[i + 2] * (1 - spillAmount * 0.5)) | |
| } | |
| // For magenta (high R+B, low G), reduce both R and B | |
| if (keyColor[0] > 200 && keyColor[2] > 200 && keyColor[1] < 100) { | |
| d[i] = Math.round(d[i] * (1 - spillAmount * 0.3)) | |
| d[i + 2] = Math.round(d[i + 2] * (1 - spillAmount * 0.3)) | |
| } | |
| } | |
| } | |
| } | |
| return { | |
| data: d, | |
| width: imageData.width, | |
| height: imageData.height | |
| } | |
| } | |
| export function createMagentaBackground(width: number, height: number, magentaColor: [number, number, number] = [255, 0, 255]): ImageData { | |
| const data = new Uint8ClampedArray(width * height * 4) | |
| for (let i = 0; i < data.length; i += 4) { | |
| data[i] = magentaColor[0] // R | |
| data[i + 1] = magentaColor[1] // G | |
| data[i + 2] = magentaColor[2] // B | |
| data[i + 3] = 255 // A (fully opaque) | |
| } | |
| return { | |
| data, | |
| width, | |
| height | |
| } | |
| } | |
| export function compositeImages(foreground: ImageData, background: ImageData): ImageData { | |
| if (foreground.width !== background.width || foreground.height !== background.height) { | |
| throw new Error('Images must have the same dimensions for compositing') | |
| } | |
| const result = new Uint8ClampedArray(foreground.data) | |
| for (let i = 0; i < result.length; i += 4) { | |
| const fgAlpha = result[i + 3] / 255 | |
| const bgAlpha = background.data[i + 3] / 255 | |
| if (fgAlpha === 0) { | |
| // Foreground is transparent, use background | |
| result[i] = background.data[i] | |
| result[i + 1] = background.data[i + 1] | |
| result[i + 2] = background.data[i + 2] | |
| result[i + 3] = background.data[i + 3] | |
| } else if (fgAlpha < 1) { | |
| // Alpha blend | |
| const invAlpha = 1 - fgAlpha | |
| result[i] = Math.round(result[i] * fgAlpha + background.data[i] * invAlpha) | |
| result[i + 1] = Math.round(result[i + 1] * fgAlpha + background.data[i + 1] * invAlpha) | |
| result[i + 2] = Math.round(result[i + 2] * fgAlpha + background.data[i + 2] * invAlpha) | |
| result[i + 3] = Math.round((fgAlpha + bgAlpha * invAlpha) * 255) | |
| } | |
| // If fgAlpha === 1, keep foreground pixel as-is | |
| } | |
| return { | |
| data: result, | |
| width: foreground.width, | |
| height: foreground.height | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>DeSepAI ChromaKey Test</title> | |
| <style> | |
| body { | |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| padding: 20px; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| } | |
| .container { | |
| background: rgba(255, 255, 255, 0.1); | |
| backdrop-filter: blur(10px); | |
| border-radius: 20px; | |
| padding: 30px; | |
| box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37); | |
| } | |
| h1 { | |
| text-align: center; | |
| margin-bottom: 30px; | |
| font-size: 2.5em; | |
| text-shadow: 2px 2px 4px rgba(0,0,0,0.3); | |
| } | |
| .upload-section { | |
| background: rgba(255, 255, 255, 0.1); | |
| border: 2px dashed rgba(255, 255, 255, 0.3); | |
| border-radius: 15px; | |
| padding: 20px; | |
| text-align: center; | |
| margin-bottom: 20px; | |
| transition: all 0.3s ease; | |
| } | |
| .upload-section:hover { | |
| border-color: rgba(255, 255, 255, 0.6); | |
| background: rgba(255, 255, 255, 0.15); | |
| } | |
| .controls { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); | |
| gap: 20px; | |
| margin-bottom: 20px; | |
| } | |
| .control-group { | |
| background: rgba(255, 255, 255, 0.1); | |
| border-radius: 10px; | |
| padding: 15px; | |
| } | |
| .control-group label { | |
| display: block; | |
| margin-bottom: 8px; | |
| font-weight: bold; | |
| } | |
| .control-group input, .control-group select { | |
| width: 100%; | |
| padding: 8px; | |
| border: none; | |
| border-radius: 5px; | |
| background: rgba(255, 255, 255, 0.9); | |
| color: #333; | |
| } | |
| .slider-container { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .slider-container input[type="range"] { | |
| flex: 1; | |
| } | |
| .slider-value { | |
| min-width: 40px; | |
| text-align: center; | |
| background: rgba(255, 255, 255, 0.2); | |
| border-radius: 5px; | |
| padding: 5px; | |
| } | |
| .color-picker-container { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .color-picker-container input[type="color"] { | |
| width: 50px; | |
| height: 30px; | |
| border: none; | |
| border-radius: 5px; | |
| cursor: pointer; | |
| } | |
| .preset-buttons { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| margin-bottom: 20px; | |
| } | |
| .preset-btn { | |
| padding: 10px 20px; | |
| border: none; | |
| border-radius: 25px; | |
| background: rgba(255, 255, 255, 0.2); | |
| color: white; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| font-weight: bold; | |
| } | |
| .preset-btn:hover { | |
| background: rgba(255, 255, 255, 0.3); | |
| transform: translateY(-2px); | |
| } | |
| .action-buttons { | |
| display: flex; | |
| gap: 15px; | |
| margin-bottom: 20px; | |
| flex-wrap: wrap; | |
| } | |
| .btn { | |
| padding: 12px 25px; | |
| border: none; | |
| border-radius: 25px; | |
| font-weight: bold; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| font-size: 16px; | |
| } | |
| .btn-primary { | |
| background: linear-gradient(45deg, #4CAF50, #45a049); | |
| color: white; | |
| } | |
| .btn-secondary { | |
| background: linear-gradient(45deg, #2196F3, #1976D2); | |
| color: white; | |
| } | |
| .btn-detect { | |
| background: linear-gradient(45deg, #FF9800, #F57C00); | |
| color: white; | |
| } | |
| .btn:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 15px rgba(0,0,0,0.2); | |
| } | |
| .btn:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| transform: none; | |
| } | |
| .results { | |
| display: none; | |
| grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); | |
| gap: 20px; | |
| margin-top: 20px; | |
| } | |
| .result-item { | |
| background: rgba(255, 255, 255, 0.1); | |
| border-radius: 10px; | |
| padding: 15px; | |
| text-align: center; | |
| } | |
| .result-item h3 { | |
| margin-top: 0; | |
| margin-bottom: 15px; | |
| } | |
| .result-item img { | |
| max-width: 100%; | |
| max-height: 300px; | |
| border-radius: 10px; | |
| box-shadow: 0 4px 15px rgba(0,0,0,0.2); | |
| background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><rect width="10" height="10" fill="%23ccc"/><rect x="10" y="10" width="10" height="10" fill="%23ccc"/><rect x="10" width="10" height="10" fill="white"/><rect y="10" width="10" height="10" fill="white"/></svg>'); | |
| background-size: 20px 20px; | |
| } | |
| .message { | |
| padding: 15px; | |
| border-radius: 10px; | |
| margin: 10px 0; | |
| font-weight: bold; | |
| } | |
| .message.info { | |
| background: rgba(33, 150, 243, 0.2); | |
| border: 1px solid rgba(33, 150, 243, 0.5); | |
| } | |
| .message.error { | |
| background: rgba(244, 67, 54, 0.2); | |
| border: 1px solid rgba(244, 67, 54, 0.5); | |
| } | |
| .message.success { | |
| background: rgba(76, 175, 80, 0.2); | |
| border: 1px solid rgba(76, 175, 80, 0.5); | |
| } | |
| .background-options { | |
| display: flex; | |
| gap: 10px; | |
| align-items: center; | |
| flex-wrap: wrap; | |
| } | |
| .background-options input[type="color"] { | |
| width: 40px; | |
| height: 40px; | |
| } | |
| .processing { | |
| opacity: 0.7; | |
| pointer-events: none; | |
| } | |
| /* Toast Notification System */ | |
| .toast-container { | |
| position: fixed !important; | |
| top: 20px !important; | |
| right: 20px !important; | |
| z-index: 10000 !important; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 10px; | |
| max-width: 400px; | |
| pointer-events: none; | |
| } | |
| /* Responsive positioning for smaller screens */ | |
| @media (max-width: 480px) { | |
| .toast-container { | |
| top: 10px !important; | |
| right: 10px !important; | |
| left: 10px !important; | |
| max-width: none; | |
| } | |
| } | |
| .toast { | |
| padding: 16px 20px; | |
| border-radius: 12px; | |
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); | |
| backdrop-filter: blur(10px); | |
| color: white; | |
| font-weight: 500; | |
| font-size: 14px; | |
| line-height: 1.4; | |
| border: 1px solid rgba(255, 255, 255, 0.2); | |
| transform: translateX(100%); | |
| opacity: 0; | |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| pointer-events: auto; | |
| cursor: pointer; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .toast.show { | |
| transform: translateX(0); | |
| opacity: 1; | |
| } | |
| .toast.hide { | |
| transform: translateX(100%); | |
| opacity: 0; | |
| } | |
| .toast.info { | |
| background: linear-gradient(135deg, rgba(33, 150, 243, 0.9), rgba(33, 150, 243, 0.7)); | |
| border-color: rgba(33, 150, 243, 0.5); | |
| } | |
| .toast.error { | |
| background: linear-gradient(135deg, rgba(244, 67, 54, 0.9), rgba(244, 67, 54, 0.7)); | |
| border-color: rgba(244, 67, 54, 0.5); | |
| } | |
| .toast.success { | |
| background: linear-gradient(135deg, rgba(76, 175, 80, 0.9), rgba(76, 175, 80, 0.7)); | |
| border-color: rgba(76, 175, 80, 0.5); | |
| } | |
| .toast::before { | |
| content: ''; | |
| position: absolute; | |
| bottom: 0; | |
| left: 0; | |
| height: 3px; | |
| background: rgba(255, 255, 255, 0.8); | |
| border-radius: 0 0 12px 12px; | |
| animation: toast-progress 3s linear forwards; | |
| } | |
| .toast.info::before { | |
| background: rgba(33, 150, 243, 1); | |
| } | |
| .toast.error::before { | |
| background: rgba(244, 67, 54, 1); | |
| } | |
| .toast.success::before { | |
| background: rgba(76, 175, 80, 1); | |
| } | |
| @keyframes toast-progress { | |
| from { width: 100%; } | |
| to { width: 0%; } | |
| } | |
| .toast:hover::before { | |
| animation-play-state: paused; | |
| } | |
| .toast-close { | |
| position: absolute; | |
| top: 8px; | |
| right: 10px; | |
| background: none; | |
| border: none; | |
| color: rgba(255, 255, 255, 0.7); | |
| font-size: 18px; | |
| cursor: pointer; | |
| padding: 0; | |
| width: 20px; | |
| height: 20px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: color 0.2s ease; | |
| } | |
| .toast-close:hover { | |
| color: rgba(255, 255, 255, 1); | |
| } | |
| .magnifier-container { | |
| position: relative; | |
| display: inline-block; | |
| } | |
| .magnifier { | |
| position: absolute; | |
| width: 120px; | |
| height: 120px; | |
| border: 3px solid #fff; | |
| border-radius: 50%; | |
| box-shadow: 0 0 20px rgba(0,0,0,0.5); | |
| pointer-events: none; | |
| z-index: 1000; | |
| display: none; | |
| background: #000; | |
| overflow: hidden; | |
| } | |
| .magnifier canvas { | |
| width: 100%; | |
| height: 100%; | |
| image-rendering: pixelated; | |
| image-rendering: -moz-crisp-edges; | |
| image-rendering: crisp-edges; | |
| } | |
| .magnifier-crosshair { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| width: 2px; | |
| height: 2px; | |
| background: red; | |
| transform: translate(-50%, -50%); | |
| z-index: 1001; | |
| } | |
| .magnifier-crosshair::before, | |
| .magnifier-crosshair::after { | |
| content: ''; | |
| position: absolute; | |
| background: red; | |
| } | |
| .magnifier-crosshair::before { | |
| width: 20px; | |
| height: 1px; | |
| top: 0.5px; | |
| left: -9px; | |
| } | |
| .magnifier-crosshair::after { | |
| width: 1px; | |
| height: 20px; | |
| top: -9px; | |
| left: 0.5px; | |
| } | |
| .color-preview { | |
| position: absolute; | |
| bottom: -30px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: rgba(0,0,0,0.8); | |
| color: white; | |
| padding: 4px 8px; | |
| border-radius: 4px; | |
| font-size: 12px; | |
| white-space: nowrap; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>DeSepAI ChromaKey Tool</h1> | |
| <div class="upload-section"> | |
| <h3>Select Image with Magenta Background</h3> | |
| <input type="file" id="fileInput" accept="image/*" style="margin: 10px;"> | |
| <p>A default magenta.png image is loaded automatically. Choose a different image with magenta background or process the default one.</p> | |
| </div> | |
| <div class="preset-buttons"> | |
| <button class="preset-btn" onclick="setPreset('default')">Default Magenta</button> | |
| <button class="preset-btn" onclick="setPreset('strict')">Strict Keying</button> | |
| <button class="preset-btn" onclick="setPreset('soft')">Soft Keying</button> | |
| <button class="preset-btn" onclick="setPreset('green')">Green Screen</button> | |
| <button class="preset-btn" onclick="setPreset('blue')">Blue Screen</button> | |
| </div> | |
| <div class="controls"> | |
| <div class="control-group"> | |
| <label>Key Colour:</label> | |
| <div class="color-picker-container"> | |
| <input type="color" id="keyColor" value="#ff00ff"> | |
| <span id="keyColorLabel">Magenta (#FF00FF)</span> | |
| </div> | |
| </div> | |
| <div class="control-group"> | |
| <label>Tolerance:</label> | |
| <div class="slider-container"> | |
| <input type="range" id="tolerance" min="0" max="100" value="20"> | |
| <span class="slider-value" id="toleranceValue">20</span> | |
| </div> | |
| </div> | |
| <div class="control-group"> | |
| <label>Softness:</label> | |
| <div class="slider-container"> | |
| <input type="range" id="softness" min="0" max="50" value="5"> | |
| <span class="slider-value" id="softnessValue">5</span> | |
| </div> | |
| </div> | |
| <div class="control-group"> | |
| <label>Spill Suppression:</label> | |
| <div class="slider-container"> | |
| <input type="range" id="spill" min="0" max="100" value="30"> | |
| <span class="slider-value" id="spillValue">30</span> | |
| </div> | |
| </div> | |
| <div class="control-group"> | |
| <label>Background:</label> | |
| <div class="background-options"> | |
| <select id="backgroundType"> | |
| <option value="transparent">Transparent</option> | |
| <option value="color">Solid Colour</option> | |
| <option value="image">Custom Image</option> | |
| </select> | |
| <input type="color" id="backgroundColor" value="#ffffff" style="display: none;"> | |
| <input type="file" id="backgroundImage" accept="image/*" style="display: none;"> | |
| </div> | |
| </div> | |
| <div class="control-group"> | |
| <label> | |
| <input type="checkbox" id="preserveEdges" checked> | |
| Preserve Edge Detail | |
| </label> | |
| </div> | |
| </div> | |
| <div class="action-buttons"> | |
| <button class="btn btn-detect" onclick="detectMagentaFromImage()">Auto-Detect Key Colour</button> | |
| <button class="btn btn-primary" id="processBtn" onclick="processImage()" disabled>Apply ChromaKey</button> | |
| <button class="btn btn-secondary" onclick="downloadResult()" id="downloadBtn" style="display: none;">Download Result</button> | |
| </div> | |
| <div class="results" id="results"> | |
| <div class="result-item"> | |
| <h3>Original Image (Click to pick chroma colour)</h3> | |
| <div class="magnifier-container"> | |
| <img id="originalImage" src="#" alt="Original" /> | |
| <div class="magnifier" id="magnifier"> | |
| <canvas id="magnifierCanvas" width="120" height="120"></canvas> | |
| <div class="magnifier-crosshair"></div> | |
| <div class="color-preview" id="colorPreview">RGB(0, 0, 0)</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="result-item"> | |
| <h3>ChromaKey Result</h3> | |
| <img id="resultImage" src="#" alt="Result" /> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Toast Container (positioned to follow viewport) --> | |
| <div class="toast-container" id="toastContainer"></div> | |
| <script> | |
| let currentFile = null; | |
| let resultBase64 = null; | |
| // ChromaKey Core Functions (converted from TypeScript) | |
| function detectMagentaColor(imageData) { | |
| const { data } = imageData; | |
| const magentaPixels = []; | |
| for (let i = 0; i < data.length; i += 4) { | |
| const r = data[i]; | |
| const g = data[i + 1]; | |
| const b = data[i + 2]; | |
| const magentaness = (r + b) - g; | |
| const brightness = (r + g + b) / 3; | |
| if (magentaness > 50 && brightness > 50) { | |
| magentaPixels.push([r, g, b]); | |
| } | |
| } | |
| if (magentaPixels.length === 0) { | |
| return [255, 0, 255]; // Default magenta | |
| } | |
| const sum = magentaPixels.reduce( | |
| (acc, pixel) => [acc[0] + pixel[0], acc[1] + pixel[1], acc[2] + pixel[2]], | |
| [0, 0, 0] | |
| ); | |
| return [ | |
| Math.round(sum[0] / magentaPixels.length), | |
| Math.round(sum[1] / magentaPixels.length), | |
| Math.round(sum[2] / magentaPixels.length) | |
| ]; | |
| } | |
| function colorDistanceHSV(c1, c2) { | |
| const hsv1 = rgbToHsv(c1); | |
| const hsv2 = rgbToHsv(c2); | |
| const dh = Math.min(Math.abs(hsv1[0] - hsv2[0]), 360 - Math.abs(hsv1[0] - hsv2[0])); | |
| const ds = Math.abs(hsv1[1] - hsv2[1]); | |
| const dv = Math.abs(hsv1[2] - hsv2[2]); | |
| return Math.sqrt(dh * dh * 4 + ds * ds + dv * dv); | |
| } | |
| function colorDistance(c1, c2) { | |
| const dr = c1[0] - c2[0]; | |
| const dg = c1[1] - c2[1]; | |
| const db = c1[2] - c2[2]; | |
| return Math.sqrt(dr * dr + dg * dg + db * db); | |
| } | |
| function rgbToHsv(rgb) { | |
| const r = rgb[0] / 255; | |
| const g = rgb[1] / 255; | |
| const b = rgb[2] / 255; | |
| const max = Math.max(r, g, b); | |
| const min = Math.min(r, g, b); | |
| const diff = max - min; | |
| let h = 0; | |
| if (diff !== 0) { | |
| if (max === r) h = 60 * (((g - b) / diff) % 6); | |
| else if (max === g) h = 60 * ((b - r) / diff + 2); | |
| else h = 60 * ((r - g) / diff + 4); | |
| } | |
| if (h < 0) h += 360; | |
| const s = max === 0 ? 0 : diff / max; | |
| const v = max; | |
| return [h, s * 100, v * 100]; | |
| } | |
| function chromaKeyPixelData(imageData, options = {}) { | |
| const { | |
| keyColor = [255, 0, 255], | |
| tolerance = 20, | |
| softness = 5, | |
| spill = 30, | |
| preserveEdges = true | |
| } = options; | |
| const d = new Uint8ClampedArray(imageData.data); | |
| const toleranceRange = (tolerance / 100) * 255; | |
| const softnessRange = (softness / 100) * 255; | |
| const spillStrength = spill / 100; | |
| for (let i = 0; i < d.length; i += 4) { | |
| const pixel = [d[i], d[i + 1], d[i + 2]]; | |
| const distance = colorDistanceHSV(pixel, keyColor); | |
| const rgbDistance = colorDistance(pixel, keyColor); | |
| const finalDistance = Math.min(distance * 2, rgbDistance); | |
| if (finalDistance <= toleranceRange) { | |
| if (finalDistance <= toleranceRange - softnessRange) { | |
| d[i + 3] = 0; // Fully transparent | |
| } else { | |
| const alpha = (finalDistance - (toleranceRange - softnessRange)) / softnessRange; | |
| d[i + 3] = Math.round(alpha * 255); | |
| } | |
| } else if (spillStrength > 0) { | |
| const spillDistance = finalDistance - toleranceRange; | |
| const spillRange = toleranceRange * 2; | |
| if (spillDistance < spillRange) { | |
| const spillFactor = 1 - (spillDistance / spillRange); | |
| const spillAmount = spillFactor * spillStrength; | |
| if (keyColor[0] > 200 && keyColor[2] > 200 && keyColor[1] < 100) { | |
| d[i] = Math.round(d[i] * (1 - spillAmount * 0.3)); | |
| d[i + 2] = Math.round(d[i + 2] * (1 - spillAmount * 0.3)); | |
| } | |
| } | |
| } | |
| } | |
| return { | |
| data: d, | |
| width: imageData.width, | |
| height: imageData.height | |
| }; | |
| } | |
| // UI Helper Functions | |
| async function getImageDataFromBase64(base64) { | |
| return new Promise((resolve, reject) => { | |
| const img = new Image(); | |
| img.onload = () => { | |
| const canvas = document.createElement("canvas"); | |
| const ctx = canvas.getContext("2d"); | |
| canvas.width = img.naturalWidth; | |
| canvas.height = img.naturalHeight; | |
| ctx.drawImage(img, 0, 0); | |
| const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); | |
| resolve(imageData); | |
| }; | |
| img.onerror = () => reject(new Error("Failed to load image")); | |
| img.src = base64; | |
| }); | |
| } | |
| function imageDataToBase64(imageData) { | |
| const canvas = document.createElement("canvas"); | |
| const ctx = canvas.getContext("2d"); | |
| canvas.width = imageData.width; | |
| canvas.height = imageData.height; | |
| const canvasImageData = ctx.createImageData(imageData.width, imageData.height); | |
| canvasImageData.data.set(imageData.data); | |
| ctx.putImageData(canvasImageData, 0, 0); | |
| return canvas.toDataURL("image/png"); | |
| } | |
| async function fileToBase64(file) { | |
| return new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onload = () => resolve(reader.result); | |
| reader.onerror = () => reject(new Error("Failed to read file")); | |
| reader.readAsDataURL(file); | |
| }); | |
| } | |
| function hexToRgb(hex) { | |
| hex = hex.replace('#', ''); | |
| return [ | |
| parseInt(hex.substring(0, 2), 16), | |
| parseInt(hex.substring(2, 4), 16), | |
| parseInt(hex.substring(4, 6), 16) | |
| ]; | |
| } | |
| function rgbToHex(rgb) { | |
| return '#' + rgb.map(c => Math.round(c).toString(16).padStart(2, '0')).join(''); | |
| } | |
| function createSolidBackground(width, height, color) { | |
| const data = new Uint8ClampedArray(width * height * 4); | |
| for (let i = 0; i < data.length; i += 4) { | |
| data[i] = color[0]; // R | |
| data[i + 1] = color[1]; // G | |
| data[i + 2] = color[2]; // B | |
| data[i + 3] = 255; // A (fully opaque) | |
| } | |
| return { | |
| data, | |
| width, | |
| height | |
| }; | |
| } | |
| function compositeImages(foreground, background) { | |
| if (foreground.width !== background.width || foreground.height !== background.height) { | |
| // Resize background to match foreground | |
| background = resizeImageData(background, foreground.width, foreground.height); | |
| } | |
| const result = new Uint8ClampedArray(foreground.data); | |
| for (let i = 0; i < result.length; i += 4) { | |
| const fgAlpha = result[i + 3] / 255; | |
| if (fgAlpha === 0) { | |
| // Foreground is transparent, use background | |
| result[i] = background.data[i]; | |
| result[i + 1] = background.data[i + 1]; | |
| result[i + 2] = background.data[i + 2]; | |
| result[i + 3] = background.data[i + 3]; | |
| } else if (fgAlpha < 1) { | |
| // Alpha blend | |
| const invAlpha = 1 - fgAlpha; | |
| result[i] = Math.round(result[i] * fgAlpha + background.data[i] * invAlpha); | |
| result[i + 1] = Math.round(result[i + 1] * fgAlpha + background.data[i + 1] * invAlpha); | |
| result[i + 2] = Math.round(result[i + 2] * fgAlpha + background.data[i + 2] * invAlpha); | |
| result[i + 3] = 255; // Result is opaque after compositing | |
| } | |
| // If fgAlpha === 1, keep foreground pixel as-is | |
| } | |
| return { | |
| data: result, | |
| width: foreground.width, | |
| height: foreground.height | |
| }; | |
| } | |
| function resizeImageData(imageData, newWidth, newHeight) { | |
| const canvas = document.createElement('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| // Create canvas with original size | |
| canvas.width = imageData.width; | |
| canvas.height = imageData.height; | |
| // Put original image data | |
| const canvasImageData = ctx.createImageData(imageData.width, imageData.height); | |
| canvasImageData.data.set(imageData.data); | |
| ctx.putImageData(canvasImageData, 0, 0); | |
| // Create new canvas with target size | |
| const resizedCanvas = document.createElement('canvas'); | |
| const resizedCtx = resizedCanvas.getContext('2d'); | |
| resizedCanvas.width = newWidth; | |
| resizedCanvas.height = newHeight; | |
| // Draw resized image | |
| resizedCtx.drawImage(canvas, 0, 0, newWidth, newHeight); | |
| // Return new image data | |
| return resizedCtx.getImageData(0, 0, newWidth, newHeight); | |
| } | |
| // UI Event Handlers | |
| function setupEventListeners() { | |
| const fileInput = document.getElementById('fileInput'); | |
| const sliders = ['tolerance', 'softness', 'spill']; | |
| const keyColorPicker = document.getElementById('keyColor'); | |
| fileInput.addEventListener('change', function(e) { | |
| if (e.target.files && e.target.files[0]) { | |
| currentFile = e.target.files[0]; | |
| document.getElementById('processBtn').disabled = false; | |
| // Show original image | |
| const reader = new FileReader(); | |
| reader.onload = function(e) { | |
| const originalImg = document.getElementById('originalImage'); | |
| originalImg.src = e.target.result; | |
| document.getElementById('results').style.display = 'grid'; | |
| // Update magnifier image data when new image loads | |
| originalImg.onload = function() { | |
| updateImageData(); | |
| }; | |
| }; | |
| reader.readAsDataURL(currentFile); | |
| clearMessages(); | |
| showInfo('Image loaded. Ready to apply ChromaKey processing.'); | |
| } | |
| }); | |
| // Setup slider event listeners | |
| sliders.forEach(sliderId => { | |
| const slider = document.getElementById(sliderId); | |
| const valueSpan = document.getElementById(sliderId + 'Value'); | |
| slider.addEventListener('input', function(e) { | |
| valueSpan.textContent = e.target.value; | |
| }); | |
| }); | |
| // Key color picker change listener | |
| keyColorPicker.addEventListener('change', function(e) { | |
| const hexColour = e.target.value; | |
| const rgbColour = hexToRgb(hexColour); | |
| updateKeyColorLabel(rgbColour, hexColour); | |
| }); | |
| // Background type selector | |
| document.getElementById('backgroundType').addEventListener('change', function(e) { | |
| const colorPicker = document.getElementById('backgroundColor'); | |
| const imagePicker = document.getElementById('backgroundImage'); | |
| colorPicker.style.display = e.target.value === 'color' ? 'inline' : 'none'; | |
| imagePicker.style.display = e.target.value === 'image' ? 'inline' : 'none'; | |
| }); | |
| } | |
| function setPreset(preset) { | |
| const keyColor = document.getElementById('keyColor'); | |
| const tolerance = document.getElementById('tolerance'); | |
| const softness = document.getElementById('softness'); | |
| const spill = document.getElementById('spill'); | |
| switch(preset) { | |
| case 'default': | |
| keyColor.value = '#ff00ff'; // Magenta | |
| tolerance.value = 20; | |
| softness.value = 5; | |
| spill.value = 30; | |
| break; | |
| case 'strict': | |
| keyColor.value = '#ff00ff'; | |
| tolerance.value = 10; | |
| softness.value = 2; | |
| spill.value = 50; | |
| break; | |
| case 'soft': | |
| keyColor.value = '#ff00ff'; | |
| tolerance.value = 35; | |
| softness.value = 15; | |
| spill.value = 20; | |
| break; | |
| case 'green': | |
| keyColor.value = '#00ff00'; // Green | |
| tolerance.value = 25; | |
| softness.value = 8; | |
| spill.value = 40; | |
| break; | |
| case 'blue': | |
| keyColor.value = '#0000ff'; // Blue | |
| tolerance.value = 25; | |
| softness.value = 8; | |
| spill.value = 40; | |
| break; | |
| } | |
| // Update value displays | |
| document.getElementById('toleranceValue').textContent = tolerance.value; | |
| document.getElementById('softnessValue').textContent = softness.value; | |
| document.getElementById('spillValue').textContent = spill.value; | |
| // Update colour label | |
| const rgbColour = hexToRgb(keyColor.value); | |
| updateKeyColorLabel(rgbColour, keyColor.value); | |
| showInfo(`Preset applied: ${preset}`); | |
| } | |
| async function detectMagentaFromImage() { | |
| if (!currentFile) { | |
| showError('Please select an image first'); | |
| return; | |
| } | |
| try { | |
| showInfo('Detecting key colour from image...'); | |
| const inputBase64 = await fileToBase64(currentFile); | |
| const imageData = await getImageDataFromBase64(inputBase64); | |
| const detectedColour = detectMagentaColor(imageData); | |
| const hexColour = rgbToHex(detectedColour); | |
| document.getElementById('keyColor').value = hexColour; | |
| // Update the colour label | |
| updateKeyColorLabel(detectedColour, hexColour); | |
| showSuccess(`Auto-detected key colour: RGB(${detectedColour.join(', ')})`); | |
| } catch (error) { | |
| showError('Auto-detection failed: ' + error.message); | |
| } | |
| } | |
| async function processImage() { | |
| if (!currentFile) { | |
| showError('Please select an image first'); | |
| return; | |
| } | |
| try { | |
| showInfo('Applying ChromaKey processing...'); | |
| document.body.classList.add('processing'); | |
| const inputBase64 = await fileToBase64(currentFile); | |
| const imageData = await getImageDataFromBase64(inputBase64); | |
| const options = { | |
| keyColor: hexToRgb(document.getElementById('keyColor').value), | |
| tolerance: parseInt(document.getElementById('tolerance').value), | |
| softness: parseInt(document.getElementById('softness').value), | |
| spill: parseInt(document.getElementById('spill').value), | |
| preserveEdges: document.getElementById('preserveEdges').checked | |
| }; | |
| // Apply chromakey to create transparency | |
| const processedImageData = chromaKeyPixelData(imageData, options); | |
| // Handle background composition | |
| const backgroundType = document.getElementById('backgroundType').value; | |
| let finalImageData = processedImageData; | |
| if (backgroundType === 'color') { | |
| const backgroundColor = hexToRgb(document.getElementById('backgroundColor').value); | |
| const backgroundImageData = createSolidBackground( | |
| processedImageData.width, | |
| processedImageData.height, | |
| backgroundColor | |
| ); | |
| finalImageData = compositeImages(processedImageData, backgroundImageData); | |
| showInfo('Composited with solid colour background'); | |
| } else if (backgroundType === 'image') { | |
| const backgroundFileInput = document.getElementById('backgroundImage'); | |
| if (backgroundFileInput.files && backgroundFileInput.files[0]) { | |
| const backgroundBase64 = await fileToBase64(backgroundFileInput.files[0]); | |
| const backgroundImageData = await getImageDataFromBase64(backgroundBase64); | |
| finalImageData = compositeImages(processedImageData, backgroundImageData); | |
| showInfo('Composited with custom background image'); | |
| } else { | |
| showInfo('No background image selected, using transparent background'); | |
| } | |
| } else { | |
| showInfo('Using transparent background'); | |
| } | |
| resultBase64 = imageDataToBase64(finalImageData); | |
| document.getElementById('resultImage').src = resultBase64; | |
| document.getElementById('downloadBtn').style.display = 'inline-block'; | |
| showSuccess('ChromaKey processing completed successfully!'); | |
| } catch (error) { | |
| showError('Processing failed: ' + error.message); | |
| } finally { | |
| document.body.classList.remove('processing'); | |
| } | |
| } | |
| function downloadResult() { | |
| if (!resultBase64) { | |
| showError('No processed image to download'); | |
| return; | |
| } | |
| const link = document.createElement('a'); | |
| link.download = 'chromakey-result.png'; | |
| link.href = resultBase64; | |
| link.click(); | |
| showInfo('Download started'); | |
| } | |
| // Toast Notification System | |
| let toastId = 0; | |
| function createToast(message, type = 'info', duration = 3000) { | |
| const toastContainer = document.getElementById('toastContainer'); | |
| if (!toastContainer) return; | |
| const id = ++toastId; | |
| const toast = document.createElement('div'); | |
| toast.className = `toast ${type}`; | |
| toast.id = `toast-${id}`; | |
| // Create close button | |
| const closeBtn = document.createElement('button'); | |
| closeBtn.className = 'toast-close'; | |
| closeBtn.innerHTML = '×'; | |
| closeBtn.onclick = () => dismissToast(id); | |
| // Set message content | |
| const messageSpan = document.createElement('span'); | |
| messageSpan.textContent = message; | |
| toast.appendChild(messageSpan); | |
| toast.appendChild(closeBtn); | |
| // Add click to dismiss | |
| toast.onclick = (e) => { | |
| if (e.target !== closeBtn) { | |
| dismissToast(id); | |
| } | |
| }; | |
| toastContainer.appendChild(toast); | |
| // Trigger show animation | |
| setTimeout(() => { | |
| toast.classList.add('show'); | |
| }, 10); | |
| // Auto dismiss | |
| setTimeout(() => { | |
| dismissToast(id); | |
| }, duration); | |
| return id; | |
| } | |
| function dismissToast(id) { | |
| const toast = document.getElementById(`toast-${id}`); | |
| if (!toast) return; | |
| toast.classList.remove('show'); | |
| toast.classList.add('hide'); | |
| setTimeout(() => { | |
| if (toast.parentNode) { | |
| toast.parentNode.removeChild(toast); | |
| } | |
| }, 300); | |
| } | |
| function showInfo(message) { | |
| createToast(message, 'info', 3000); | |
| } | |
| function showError(message) { | |
| createToast(message, 'error', 5000); | |
| } | |
| function showSuccess(message) { | |
| createToast(message, 'success', 4000); | |
| } | |
| function clearMessages() { | |
| const toastContainer = document.getElementById('toastContainer'); | |
| if (toastContainer) { | |
| toastContainer.innerHTML = ''; | |
| } | |
| } | |
| function updateKeyColorLabel(rgbColor, hexColor) { | |
| const label = document.getElementById('keyColorLabel'); | |
| const colourName = getColourName(rgbColor); | |
| label.textContent = `${colourName} (${hexColor.toUpperCase()})`; | |
| } | |
| function getColourName(rgb) { | |
| const [r, g, b] = rgb; | |
| // Pure colours with tight tolerances first (most specific) | |
| if (r > 250 && g < 10 && b > 250) return 'Fuchsia-ish'; // #FF00FF | |
| if (r > 250 && g < 10 && b < 10) return 'Red-ish'; // #FF0000 | |
| if (r < 10 && g > 250 && b < 10) return 'Lime-ish'; // #00FF00 | |
| if (r < 10 && g < 10 && b > 250) return 'Blue-ish'; // #0000FF | |
| if (r > 250 && g > 250 && b < 10) return 'Yellow-ish'; // #FFFF00 | |
| if (r < 10 && g > 250 && b > 250) return 'Aqua-ish'; // #00FFFF | |
| if (r > 250 && g > 250 && b > 250) return 'White-ish'; // #FFFFFF | |
| if (r < 10 && g < 10 && b < 10) return 'Black-ish'; // #000000 | |
| // Specific named colours with medium tolerances | |
| if (r > 220 && r < 255 && g > 20 && g < 70 && b > 120 && b < 170) return 'DeepPink-ish'; // #FF1493 | |
| if (r > 200 && r < 255 && g > 90 && g < 140 && b > 170 && b < 220) return 'HotPink-ish'; // #FF69B4 | |
| if (r > 230 && r < 255 && g > 160 && g < 210 && b > 190 && b < 240) return 'Pink-ish'; // #FFC0CB | |
| if (r > 110 && r < 160 && g > 20 && g < 70 && b > 110 && b < 160) return 'Purple-ish'; // #800080 | |
| if (r > 70 && r < 120 && g < 30 && b > 100 && b < 150) return 'Indigo-ish'; // #4B0082 | |
| if (r > 230 && r < 255 && g > 120 && g < 170 && b < 30) return 'Orange-ish'; // #FFA500 | |
| if (r > 240 && r < 255 && g > 35 && g < 85 && b < 30) return 'OrangeRed-ish'; // #FF4500 | |
| if (r > 220 && r < 255 && g > 10 && g < 60 && b > 130 && b < 180) return 'Crimson-ish'; // #DC143C | |
| if (r > 170 && r < 220 && g > 30 && g < 80 && b > 40 && b < 90) return 'Brown-ish'; // #A52A2A | |
| if (r > 120 && r < 170 && g > 40 && g < 90 && b > 10 && b < 60) return 'SaddleBrown-ish'; // #8B4513 | |
| if (r > 160 && r < 210 && g > 80 && g < 130 && b > 30 && b < 80) return 'Chocolate-ish'; // #D2691E | |
| if (r > 200 && r < 240 && g > 170 && g < 210 && b > 100 && b < 150) return 'Tan-ish'; // #D2B48C | |
| if (r > 230 && r < 255 && g > 220 && g < 255 && b > 160 && b < 210) return 'Beige-ish'; // #F5F5DC | |
| if (r > 240 && r < 255 && g > 240 && g < 255 && b > 220 && b < 255) return 'Ivory-ish'; // #FFFFF0 | |
| if (r > 90 && r < 140 && g > 100 && g < 150 && b > 100 && b < 150) return 'Grey-ish'; // #808080 | |
| if (r > 160 && r < 210 && g > 160 && g < 210 && b > 160 && b < 210) return 'Silver-ish'; // #C0C0C0 | |
| if (r > 30 && r < 80 && g > 70 && g < 120 && b > 30 && b < 80) return 'DarkOliveGreen-ish'; // #556B2F | |
| if (r > 100 && r < 150 && g > 140 && g < 190 && b > 40 && b < 90) return 'Olive-ish'; // #808000 | |
| if (r > 140 && r < 190 && g > 200 && g < 250 && b > 30 && b < 80) return 'YellowGreen-ish'; // #9ACD32 | |
| if (r > 120 && r < 170 && g > 190 && g < 240 && b > 120 && b < 170) return 'LightGreen-ish'; // #90EE90 | |
| if (r > 30 && r < 80 && g > 90 && g < 140 && b > 30 && b < 80) return 'ForestGreen-ish'; // #228B22 | |
| if (r > 20 && r < 70 && g > 90 && g < 140 && b > 70 && b < 120) return 'SeaGreen-ish'; // #2E8B57 | |
| if (r > 60 && r < 110 && g > 150 && g < 200 && b > 150 && b < 200) return 'MediumSeaGreen-ish'; // #3CB371 | |
| if (r > 0 && r < 50 && g > 180 && g < 230 && b > 140 && b < 190) return 'SpringGreen-ish'; // #00FF7F | |
| if (r > 90 && r < 140 && g > 200 && g < 250 && b > 200 && b < 250) return 'Turquoise-ish'; // #40E0D0 | |
| if (r > 140 && r < 190 && g > 230 && g < 255 && b > 230 && b < 255) return 'LightCyan-ish'; // #E0FFFF | |
| if (r > 90 && r < 140 && g > 150 && g < 200 && b > 200 && b < 250) return 'SkyBlue-ish'; // #87CEEB | |
| if (r > 160 && r < 210 && g > 210 && g < 255 && b > 230 && b < 255) return 'LightBlue-ish'; // #ADD8E6 | |
| if (r > 90 && r < 140 && g > 90 && g < 140 && b > 220 && b < 255) return 'RoyalBlue-ish'; // #4169E1 | |
| if (r > 20 && r < 70 && g > 20 && g < 70 && b > 130 && b < 180) return 'DarkBlue-ish'; // #00008B | |
| if (r < 30 && g < 30 && b > 120 && b < 170) return 'Navy-ish'; // #000080 | |
| if (r > 120 && r < 170 && g > 100 && g < 150 && b > 210 && b < 255) return 'SlateBlue-ish'; // #6A5ACD | |
| if (r > 140 && r < 190 && g > 60 && g < 110 && b > 200 && b < 250) return 'MediumSlateBlue-ish'; // #7B68EE | |
| if (r > 210 && r < 255 && g > 150 && g < 200 && b > 230 && b < 255) return 'Lavender-ish'; // #E6E6FA | |
| if (r > 210 && r < 255 && g > 160 && g < 210 && b > 240 && b < 255) return 'Thistle-ish'; // #D8BFD8 | |
| if (r > 180 && r < 230 && g > 120 && g < 170 && b > 200 && b < 250) return 'Plum-ish'; // #DDA0DD | |
| if (r > 190 && r < 240 && g > 90 && g < 140 && b > 190 && b < 240) return 'Violet-ish'; // #EE82EE | |
| if (r > 210 && r < 255 && g > 100 && g < 150 && b > 210 && b < 255) return 'Orchid-ish'; // #DA70D6 | |
| if (r > 180 && r < 230 && g > 50 && g < 100 && b > 180 && b < 230) return 'MediumOrchid-ish'; // #BA55D3 | |
| if (r > 140 && r < 190 && g > 30 && g < 80 && b > 190 && b < 240) return 'BlueViolet-ish'; // #8A2BE2 | |
| // Broader colour categories with wider tolerances (less specific) | |
| if (r > 200 && g < 100 && b > 150) return 'Magenta-ish'; | |
| if (r < 100 && g > 200 && b < 100) return 'Green-ish'; | |
| if (r < 100 && g < 100 && b > 200) return 'Blue-ish'; | |
| if (r > 200 && g < 100 && b < 100) return 'Red-ish'; | |
| if (r > 200 && g > 200 && b < 100) return 'Yellow-ish'; | |
| if (r < 100 && g > 200 && b > 200) return 'Cyan-ish'; | |
| if (r > 150 && g > 100 && b < 100) return 'Orange-ish'; | |
| if (r > 100 && g < 100 && b > 150) return 'Purple-ish'; | |
| if (r > 180 && g > 180 && b > 180) return 'Light-ish'; | |
| if (r < 80 && g < 80 && b < 80) return 'Dark-ish'; | |
| // Final fallback based on dominant component | |
| const max = Math.max(r, g, b); | |
| const secondMax = Math.max(...[r, g, b].filter(x => x !== max)); | |
| if (max - secondMax < 30) { | |
| // Similar values - mixed colour | |
| if (r === max && g === secondMax) return 'Orange-ish'; | |
| if (r === max && b === secondMax) return 'Pink-ish'; | |
| if (g === max && b === secondMax) return 'Teal-ish'; | |
| if (g === max && r === secondMax) return 'Chartreuse-ish'; | |
| if (b === max && r === secondMax) return 'Violet-ish'; | |
| if (b === max && g === secondMax) return 'Azure-ish'; | |
| } | |
| // Pure dominant component | |
| if (r === max) return 'Red-ish'; | |
| if (g === max) return 'Green-ish'; | |
| if (b === max) return 'Blue-ish'; | |
| return 'Custom Colour'; | |
| } | |
| // Magnifier functionality | |
| let magnifierCanvas = null; | |
| let magnifierCtx = null; | |
| let originalImageElement = null; | |
| let magnifierElement = null; | |
| let colorPreviewElement = null; | |
| let imageCanvas = null; | |
| let imageCtx = null; | |
| let imageData = null; | |
| function initializeMagnifier() { | |
| magnifierCanvas = document.getElementById('magnifierCanvas'); | |
| magnifierCtx = magnifierCanvas.getContext('2d'); | |
| originalImageElement = document.getElementById('originalImage'); | |
| magnifierElement = document.getElementById('magnifier'); | |
| colorPreviewElement = document.getElementById('colorPreview'); | |
| // Create off-screen canvas for pixel data access | |
| imageCanvas = document.createElement('canvas'); | |
| imageCtx = imageCanvas.getContext('2d'); | |
| // Set up event listeners | |
| originalImageElement.addEventListener('mouseenter', showMagnifier); | |
| originalImageElement.addEventListener('mouseleave', hideMagnifier); | |
| originalImageElement.addEventListener('mousemove', updateMagnifier); | |
| originalImageElement.addEventListener('click', pickColor); | |
| // Update image data when image loads | |
| originalImageElement.addEventListener('load', updateImageData); | |
| } | |
| function updateImageData() { | |
| if (!originalImageElement.complete || !originalImageElement.naturalWidth) return; | |
| imageCanvas.width = originalImageElement.naturalWidth; | |
| imageCanvas.height = originalImageElement.naturalHeight; | |
| imageCtx.drawImage(originalImageElement, 0, 0); | |
| imageData = imageCtx.getImageData(0, 0, imageCanvas.width, imageCanvas.height); | |
| } | |
| function showMagnifier() { | |
| if (magnifierElement) { | |
| magnifierElement.style.display = 'block'; | |
| } | |
| } | |
| function hideMagnifier() { | |
| if (magnifierElement) { | |
| magnifierElement.style.display = 'none'; | |
| } | |
| } | |
| function updateMagnifier(e) { | |
| if (!imageData || !magnifierElement || !magnifierCanvas) return; | |
| const rect = originalImageElement.getBoundingClientRect(); | |
| const scaleX = originalImageElement.naturalWidth / rect.width; | |
| const scaleY = originalImageElement.naturalHeight / rect.height; | |
| // Get mouse position relative to image | |
| const mouseX = (e.clientX - rect.left) * scaleX; | |
| const mouseY = (e.clientY - rect.top) * scaleY; | |
| // Position magnifier above and slightly to the right of cursor | |
| const magnifierX = e.clientX - rect.left + 20; | |
| const magnifierY = e.clientY - rect.top - 140; | |
| magnifierElement.style.left = magnifierX + 'px'; | |
| magnifierElement.style.top = magnifierY + 'px'; | |
| // Get color at exact mouse position | |
| const pixelX = Math.floor(mouseX); | |
| const pixelY = Math.floor(mouseY); | |
| const centerColor = getPixelColor(pixelX, pixelY); | |
| // Update color preview | |
| if (colorPreviewElement && centerColor) { | |
| colorPreviewElement.textContent = `RGB(${centerColor[0]}, ${centerColor[1]}, ${centerColor[2]})`; | |
| colorPreviewElement.style.backgroundColor = `rgb(${centerColor[0]}, ${centerColor[1]}, ${centerColor[2]})`; | |
| colorPreviewElement.style.color = (centerColor[0] + centerColor[1] + centerColor[2]) / 3 > 128 ? 'black' : 'white'; | |
| } | |
| // Draw magnified area (16x16 pixels around mouse) | |
| magnifierCtx.imageSmoothingEnabled = false; | |
| magnifierCtx.clearRect(0, 0, 120, 120); | |
| const radius = 8; // 16x16 area (8 pixels in each direction) | |
| const magnification = 120 / 16; // Scale to fit 120px canvas | |
| for (let y = -radius; y < radius; y++) { | |
| for (let x = -radius; x < radius; x++) { | |
| const srcX = Math.floor(mouseX) + x; | |
| const srcY = Math.floor(mouseY) + y; | |
| const color = getPixelColor(srcX, srcY); | |
| if (color) { | |
| magnifierCtx.fillStyle = `rgb(${color[0]}, ${color[1]}, ${color[2]})`; | |
| magnifierCtx.fillRect( | |
| (x + radius) * magnification, | |
| (y + radius) * magnification, | |
| magnification, | |
| magnification | |
| ); | |
| } | |
| } | |
| } | |
| } | |
| function getPixelColor(x, y) { | |
| if (!imageData || x < 0 || y < 0 || x >= imageData.width || y >= imageData.height) { | |
| return null; | |
| } | |
| const index = (Math.floor(y) * imageData.width + Math.floor(x)) * 4; | |
| return [ | |
| imageData.data[index], // R | |
| imageData.data[index + 1], // G | |
| imageData.data[index + 2] // B | |
| ]; | |
| } | |
| function pickColor(e) { | |
| if (!imageData) return; | |
| const rect = originalImageElement.getBoundingClientRect(); | |
| const scaleX = originalImageElement.naturalWidth / rect.width; | |
| const scaleY = originalImageElement.naturalHeight / rect.height; | |
| const mouseX = Math.floor((e.clientX - rect.left) * scaleX); | |
| const mouseY = Math.floor((e.clientY - rect.top) * scaleY); | |
| const color = getPixelColor(mouseX, mouseY); | |
| if (color) { | |
| const hexColor = rgbToHex(color); | |
| document.getElementById('keyColor').value = hexColor; | |
| updateKeyColorLabel(color, hexColor); | |
| showSuccess(`Picked colour: RGB(${color[0]}, ${color[1]}, ${color[2]}) - ${getColourName(color)}`); | |
| // Hide magnifier after picking | |
| hideMagnifier(); | |
| // Re-show after a brief delay | |
| setTimeout(() => { | |
| if (originalImageElement.matches(':hover')) { | |
| showMagnifier(); | |
| } | |
| }, 100); | |
| } | |
| } | |
| // Load default magenta.png image | |
| async function loadDefaultImage() { | |
| try { | |
| console.log('Loading default magenta.png image...'); | |
| console.log('Current working directory:', window.location.href); | |
| // Fetch magenta.png and convert to File object | |
| const response = await fetch('./magenta.png'); | |
| console.log('Fetch response status:', response.status, response.statusText); | |
| if (!response.ok) { | |
| throw new Error(`Failed to fetch magenta.png: ${response.status} ${response.statusText}`); | |
| } | |
| const blob = await response.blob(); | |
| console.log('Blob created, size:', blob.size, 'type:', blob.type); | |
| const file = new File([blob], 'magenta.png', { type: 'image/png' }); | |
| console.log('File object created:', file.name, file.size, file.type); | |
| // Set as current file | |
| currentFile = file; | |
| console.log('currentFile set to:', currentFile.name); | |
| // Show original image | |
| const base64 = await fileToBase64(file); | |
| console.log('Base64 conversion successful, length:', base64.length); | |
| const originalImg = document.getElementById('originalImage'); | |
| const resultsDiv = document.getElementById('results'); | |
| const processBtn = document.getElementById('processBtn'); | |
| if (!originalImg || !resultsDiv || !processBtn) { | |
| throw new Error('Required DOM elements not found'); | |
| } | |
| originalImg.src = base64; | |
| resultsDiv.style.display = 'grid'; | |
| processBtn.disabled = false; | |
| console.log('Default magenta.png image loaded successfully'); | |
| showInfo('Magenta.png loaded as default image. Ready to apply ChromaKey!'); | |
| } catch (error) { | |
| console.error('Failed to load default image:', error); | |
| showError('Failed to load default magenta.png image: ' + error.message); | |
| showInfo('Select an image file with magenta background to begin processing'); | |
| } | |
| } | |
| // Initialise | |
| document.addEventListener('DOMContentLoaded', function() { | |
| setupEventListeners(); | |
| initializeMagnifier(); | |
| loadDefaultImage(); | |
| showInfo('ChromaKey tool loaded. Loading default magenta.png image...'); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
The html file is just to show the kind of thing the .ts file can do, but doesn't actually call it.