glitchRenderer.ts
1 2 import { FilterState } from '../types'; 3 4 // Singleton to store reusable assets (patterns, noise canvas) 5 const ASSETS = { 6 noiseCanvas: null as HTMLCanvasElement | null, 7 scanlinePattern: null as CanvasPattern | null, 8 }; 9 10 /** 11 * Pre-generates a 512x512 noise canvas to be drawn efficiently. 12 */ 13 const getNoiseCanvas = (): HTMLCanvasElement => { 14 if (ASSETS.noiseCanvas) return ASSETS.noiseCanvas; 15 16 const canvas = document.createElement('canvas'); 17 canvas.width = 512; 18 canvas.height = 512; 19 const ctx = canvas.getContext('2d'); 20 if (ctx) { 21 const idata = ctx.createImageData(512, 512); 22 const buffer32 = new Uint32Array(idata.data.buffer); 23 for (let i = 0; i < buffer32.length; i++) { 24 // Random grayscale noise with transparency 25 const v = (Math.random() * 255) | 0; 26 // ABGR format (Little Endian) - A=100ish (semi-transparent) 27 buffer32[i] = (100 << 24) | (v << 16) | (v << 8) | v; 28 } 29 ctx.putImageData(idata, 0, 0); 30 } 31 ASSETS.noiseCanvas = canvas; 32 return canvas; 33 }; 34 35 /** 36 * Creates a simple scanline pattern (3px dark, 1px clear) 37 */ 38 const getScanlinePattern = (ctx: CanvasRenderingContext2D): CanvasPattern | null => { 39 if (ASSETS.scanlinePattern) return ASSETS.scanlinePattern; 40 41 const patternCanvas = document.createElement('canvas'); 42 patternCanvas.width = 1; 43 patternCanvas.height = 4; 44 const pCtx = patternCanvas.getContext('2d'); 45 if (!pCtx) return null; 46 47 // Background (Dark) 48 pCtx.fillStyle = "rgba(0,0,0,0.5)"; // Dark line opacity 49 pCtx.fillRect(0, 0, 1, 3); 50 // Gap (Clear) 51 pCtx.clearRect(0, 3, 1, 1); 52 53 const pattern = ctx.createPattern(patternCanvas, 'repeat'); 54 ASSETS.scanlinePattern = pattern; 55 return pattern; 56 }; 57 58 /** 59 * Main rendering function. Works for both Images and Video Frames. 60 */ 61 export const renderFrame = ( 62 ctx: CanvasRenderingContext2D, 63 source: ImageBitmap | HTMLVideoElement, 64 width: number, 65 height: number, 66 filters: FilterState 67 ) => { 68 // 1. Clear 69 ctx.clearRect(0, 0, width, height); 70 71 // --- Filters applied via Context Filter (Performant for Full Frame) --- 72 // Apply Grayscale here using native filter 73 if (filters.grayscale > 0) { 74 ctx.filter = `grayscale(${filters.grayscale}%)`; 75 } else { 76 ctx.filter = 'none'; 77 } 78 79 // 2. Draw Base Image 80 const rgbShift = filters.rgbShift; // 0 - 100 81 const hasGlitch = filters.glitchIntensity > 0; 82 83 // Determine if we trigger an "Intense" glitch frame based on random chance 84 const isIntenseFrame = hasGlitch && Math.random() < (filters.glitchIntensity / 100) * 0.1; 85 86 if (rgbShift > 0 || isIntenseFrame) { 87 const shiftX = (rgbShift / 100) * 20 + (isIntenseFrame ? 40 : 0); 88 89 // Center 90 ctx.globalAlpha = 1.0; 91 ctx.drawImage(source, 0, 0, width, height); 92 93 // Red - Shift Left 94 // 'screen' blends additively. 95 ctx.globalCompositeOperation = 'screen'; 96 ctx.globalAlpha = 0.6; 97 98 ctx.drawImage(source, -shiftX, 0, width, height); 99 100 // Blue - Shift Right 101 ctx.drawImage(source, shiftX, 0, width, height); 102 103 // Reset 104 ctx.globalAlpha = 1.0; 105 ctx.globalCompositeOperation = 'source-over'; 106 107 } else { 108 // Standard draw 109 ctx.drawImage(source, 0, 0, width, height); 110 } 111 112 // Reset filter for subsequent operations (like Blur Mosaic, which shouldn't be grayed twice if not needed) 113 ctx.filter = 'none'; 114 115 // 3. Horizontal Displacement (Glitch Strips) 116 if (filters.glitchIntensity > 0) { 117 const intensity = filters.glitchIntensity / 100; 118 // Chance to glitch this frame 119 if (Math.random() < 0.3 + (intensity * 0.5)) { 120 const numStrips = Math.floor(Math.random() * 10 * intensity) + 1; 121 122 for (let i = 0; i < numStrips; i++) { 123 const stripY = Math.random() * height; 124 const stripH = Math.random() * (height * 0.1) + 5; // 5px to 10% height 125 const shift = (Math.random() - 0.5) * (width * 0.4 * intensity); // Up to 20% width shift 126 127 // Draw a slice of the ORIGINAL source offset 128 // Note: source might need grayscale if we want the strips to match the base, 129 // but often glitch strips looking "raw" is a nice aesthetic. 130 // However, if user wants B&W, strips should be B&W. 131 132 if (filters.grayscale > 0) ctx.filter = `grayscale(${filters.grayscale}%)`; 133 134 ctx.drawImage( 135 source, 136 0, stripY, width, stripH, // Source coords 137 shift, stripY, width, stripH // Dest coords 138 ); 139 140 ctx.filter = 'none'; 141 } 142 } 143 } 144 145 // 4. Blur Regions (Mosaic) 146 if (filters.blurRegions.length > 0) { 147 const mosaicLevel = 0.03; // Block size ratio 148 ctx.imageSmoothingEnabled = false; 149 150 filters.blurRegions.forEach(region => { 151 const { x, y, w, h } = region; 152 if (w < 1 || h < 1) return; 153 154 const smallW = Math.max(1, Math.floor(w * mosaicLevel)); 155 const smallH = Math.max(1, Math.floor(h * mosaicLevel)); 156 157 const offCanvas = new OffscreenCanvas(smallW, smallH); 158 const offCtx = offCanvas.getContext('2d'); 159 if (!offCtx) return; 160 161 // Grab what is currently on the main canvas 162 offCtx.drawImage(ctx.canvas, x, y, w, h, 0, 0, smallW, smallH); 163 164 // Draw it back stretched 165 ctx.drawImage(offCanvas, 0, 0, smallW, smallH, x, y, w, h); 166 }); 167 168 ctx.imageSmoothingEnabled = true; 169 } 170 171 // 5. Scanlines 172 if (filters.scanlines > 0) { 173 const pattern = getScanlinePattern(ctx); 174 if (pattern) { 175 ctx.globalAlpha = filters.scanlines / 100; 176 ctx.fillStyle = pattern; 177 ctx.fillRect(0, 0, width, height); 178 ctx.globalAlpha = 1.0; 179 } 180 } 181 182 // 6. Noise (Static Overlay) 183 if (filters.noise > 0) { 184 const noise = getNoiseCanvas(); 185 ctx.globalAlpha = (filters.noise / 100) * 0.4; // Max 40% opacity 186 ctx.globalCompositeOperation = 'overlay'; 187 188 const noiseX = Math.random() * -100; 189 const noiseY = Math.random() * -100; 190 191 ctx.drawImage(noise, noiseX, noiseY, width + 100, height + 100); 192 193 ctx.globalCompositeOperation = 'source-over'; 194 ctx.globalAlpha = 1.0; 195 } 196 };