/ utils / glitchRenderer.ts
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  };