"use client"; import React, { useRef, useEffect, useState, useCallback } from "react"; import { cn } from "@/lib/utils"; /** Rotstufen: dunkel → hell (Helligkeit der Webcam steuert Position im Verlauf). */ const DEFAULT_REDSCALE_STOPS: string[] = [ "#4B1C1B", "#6A2725", "#883330", "#B54440", "#C55D59", "#CF7A77", ]; function parseHexRgb(hex: string): { r: number; g: number; b: number } { const h = hex.replace("#", ""); return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16), }; } function sampleRedscale( t: number, stops: { r: number; g: number; b: number }[], ): { r: number; g: number; b: number } { if (stops.length === 0) return { r: 0, g: 0, b: 0 }; if (stops.length === 1) return stops[0]; const tClamped = Math.min(1, Math.max(0, t)); const scaled = tClamped * (stops.length - 1); const i = Math.min(stops.length - 2, Math.floor(scaled)); const f = scaled - i; const a = stops[i]; const b = stops[i + 1]; return { r: Math.round(a.r + (b.r - a.r) * f), g: Math.round(a.g + (b.g - a.g) * f), b: Math.round(a.b + (b.b - a.b) * f), }; } type WebcamPixelGridProps = { /** Number of columns in the grid */ gridCols?: number; /** Number of rows in the grid */ gridRows?: number; /** Maximum elevation for motion detection */ maxElevation?: number; /** Motion sensitivity (0-1) */ motionSensitivity?: number; /** Smoothing factor for elevation transitions */ elevationSmoothing?: number; /** Color mode: webcam = Farben; monochrome = ein Kanal; redscale = Rotstufen nach Helligkeit */ colorMode?: "webcam" | "monochrome" | "redscale"; /** Monochrome mode: einzige Tinte (Hex) */ monochromeColor?: string; /** Redscale mode: Stufen von dunkel zu hell (Hex), Standard = Marken-Rotverlauf */ redscaleStops?: string[]; /** Background color */ backgroundColor?: string; /** Whether to mirror the webcam feed */ mirror?: boolean; /** Gap between cells (0-1, fraction of cell size) */ gapRatio?: number; /** Invert the colors */ invertColors?: boolean; /** Darken factor (0-1, 0 = no darkening, 1 = fully dark) */ darken?: number; /** Border color for cells */ borderColor?: string; /** Border opacity (0-1) */ borderOpacity?: number; /** Additional class name */ className?: string; /** Callback when webcam access is denied */ onWebcamError?: (error: Error) => void; /** Callback when webcam is ready */ onWebcamReady?: () => void; /** If true, no toast or floating error UI (still calls onWebcamError) */ quietWebcamErrors?: boolean; }; type PixelData = { r: number; g: number; b: number; motion: number; targetElevation: number; currentElevation: number; }; export const WebcamPixelGrid: React.FC = ({ gridCols = 64, gridRows = 48, maxElevation = 15, motionSensitivity = 0.4, elevationSmoothing = 0.1, colorMode = "webcam", monochromeColor = "#00ff88", backgroundColor = "#0a0a0a", mirror = true, gapRatio = 0.1, invertColors = false, darken = 0, borderColor = "#ffffff", borderOpacity = 0.08, className, onWebcamError, onWebcamReady, quietWebcamErrors = false, redscaleStops = DEFAULT_REDSCALE_STOPS, }) => { const videoRef = useRef(null); const processingCanvasRef = useRef(null); const displayCanvasRef = useRef(null); const previousFrameRef = useRef(null); const pixelDataRef = useRef([]); const animationRef = useRef(0); const [isReady, setIsReady] = useState(false); const [error, setError] = useState(null); const [showErrorPopup, setShowErrorPopup] = useState(true); // Parse monochrome color const monoRGB = React.useMemo(() => { const hex = monochromeColor.replace("#", ""); return { r: parseInt(hex.slice(0, 2), 16), g: parseInt(hex.slice(2, 4), 16), b: parseInt(hex.slice(4, 6), 16), }; }, [monochromeColor]); const redscaleRGBStops = React.useMemo( () => redscaleStops.map(parseHexRgb), [redscaleStops], ); // Parse border color const borderRGB = React.useMemo(() => { const hex = borderColor.replace("#", ""); return { r: parseInt(hex.slice(0, 2), 16), g: parseInt(hex.slice(2, 4), 16), b: parseInt(hex.slice(4, 6), 16), }; }, [borderColor]); // Initialize pixel data useEffect(() => { pixelDataRef.current = Array.from({ length: gridRows }, () => Array.from({ length: gridCols }, () => ({ r: 30, g: 30, b: 30, motion: 0, targetElevation: 0, currentElevation: 0, })), ); }, [gridCols, gridRows]); const streamRef = useRef(null); // Request camera access const requestCameraAccess = useCallback(async () => { try { if (!navigator.mediaDevices?.getUserMedia) { throw new Error("Webcam access is not available in this browser"); } const stream = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: "user", }, }); streamRef.current = stream; if (videoRef.current) { videoRef.current.srcObject = stream; await videoRef.current.play(); setIsReady(true); setError(null); setShowErrorPopup(false); onWebcamReady?.(); } } catch (err) { const error = err instanceof Error ? err : new Error("Webcam access denied"); setError(error.message); onWebcamError?.(error); } }, [onWebcamError, onWebcamReady]); // Initialize webcam on mount useEffect(() => { requestCameraAccess(); return () => { if (streamRef.current) { streamRef.current.getTracks().forEach((track) => track.stop()); } }; }, [requestCameraAccess]); // Main render loop const render = useCallback(() => { const video = videoRef.current; const processingCanvas = processingCanvasRef.current; const displayCanvas = displayCanvasRef.current; if (!video || !processingCanvas || !displayCanvas || video.readyState < 2) { animationRef.current = requestAnimationFrame(render); return; } const procCtx = processingCanvas.getContext("2d", { willReadFrequently: true, }); const dispCtx = displayCanvas.getContext("2d"); if (!procCtx || !dispCtx) { animationRef.current = requestAnimationFrame(render); return; } // Set processing canvas size to grid dimensions processingCanvas.width = gridCols; processingCanvas.height = gridRows; // Draw video to processing canvas (scaled down) procCtx.save(); if (mirror) { procCtx.scale(-1, 1); procCtx.drawImage(video, -gridCols, 0, gridCols, gridRows); } else { procCtx.drawImage(video, 0, 0, gridCols, gridRows); } procCtx.restore(); // Get pixel data const imageData = procCtx.getImageData(0, 0, gridCols, gridRows); const currentData = imageData.data; const previousData = previousFrameRef.current; // Update pixel data with motion detection const pixels = pixelDataRef.current; for (let row = 0; row < gridRows; row++) { for (let col = 0; col < gridCols; col++) { const idx = (row * gridCols + col) * 4; const r = currentData[idx]; const g = currentData[idx + 1]; const b = currentData[idx + 2]; const pixel = pixels[row]?.[col]; if (!pixel) continue; // Calculate motion let motion = 0; if (previousData) { const prevR = previousData[idx]; const prevG = previousData[idx + 1]; const prevB = previousData[idx + 2]; const diff = Math.abs(r - prevR) + Math.abs(g - prevG) + Math.abs(b - prevB); motion = Math.min(1, diff / 255 / motionSensitivity); } // Smooth motion pixel.motion = pixel.motion * 0.7 + motion * 0.3; // Set colors let finalR = r; let finalG = g; let finalB = b; if (colorMode === "monochrome") { const brightness = (r + g + b) / 3 / 255; finalR = Math.round(monoRGB.r * brightness); finalG = Math.round(monoRGB.g * brightness); finalB = Math.round(monoRGB.b * brightness); } else if (colorMode === "redscale") { const brightness = (r + g + b) / 3 / 255; const t = Math.pow(Math.min(1, Math.max(0, brightness)), 0.88); const { r: sr, g: sg, b: sb } = sampleRedscale(t, redscaleRGBStops); finalR = sr; finalG = sg; finalB = sb; } // Apply invert if (invertColors) { finalR = 255 - finalR; finalG = 255 - finalG; finalB = 255 - finalB; } // Apply darken if (darken > 0) { const darkenFactor = 1 - darken; finalR = Math.round(finalR * darkenFactor); finalG = Math.round(finalG * darkenFactor); finalB = Math.round(finalB * darkenFactor); } pixel.r = finalR; pixel.g = finalG; pixel.b = finalB; // Set target elevation pixel.targetElevation = pixel.motion * maxElevation; // Smooth elevation transition pixel.currentElevation += (pixel.targetElevation - pixel.currentElevation) * elevationSmoothing; } } // Store current frame for next comparison previousFrameRef.current = new Uint8ClampedArray(currentData); // Render to display canvas const dpr = window.devicePixelRatio || 1; const displayWidth = displayCanvas.clientWidth; const displayHeight = displayCanvas.clientHeight; displayCanvas.width = displayWidth * dpr; displayCanvas.height = displayHeight * dpr; dispCtx.scale(dpr, dpr); // Clear canvas dispCtx.fillStyle = backgroundColor; dispCtx.fillRect(0, 0, displayWidth, displayHeight); // Calculate cell size (always square, cover entire container like object-fit: cover) const cellSize = Math.max( displayWidth / gridCols, displayHeight / gridRows, ); const gap = cellSize * gapRatio; // Calculate offset to center the grid (negative offset for overflow, creating cover effect) const gridWidth = cellSize * gridCols; const gridHeight = cellSize * gridRows; const offsetXGrid = (displayWidth - gridWidth) / 2; const offsetYGrid = (displayHeight - gridHeight) / 2; // Draw cells with 3D effect for (let row = 0; row < gridRows; row++) { for (let col = 0; col < gridCols; col++) { const pixel = pixels[row]?.[col]; if (!pixel) continue; const x = offsetXGrid + col * cellSize; const y = offsetYGrid + row * cellSize; const elevation = pixel.currentElevation; // Calculate 3D offset (isometric-like projection) - MUCH larger effect const offsetX = -elevation * 1.2; const offsetY = -elevation * 1.8; // Draw shadow - larger and more visible if (elevation > 0.5) { dispCtx.fillStyle = `rgba(0, 0, 0, ${Math.min(0.6, elevation * 0.04)})`; dispCtx.fillRect( x + gap / 2 + elevation * 1.5, y + gap / 2 + elevation * 2.0, cellSize - gap, cellSize - gap, ); } // Draw side faces for 3D effect - thicker sides if (elevation > 0.5) { // Right side dispCtx.fillStyle = `rgb(${Math.max(0, pixel.r - 80)}, ${Math.max(0, pixel.g - 80)}, ${Math.max(0, pixel.b - 80)})`; dispCtx.beginPath(); dispCtx.moveTo( x + cellSize - gap / 2 + offsetX, y + gap / 2 + offsetY, ); dispCtx.lineTo(x + cellSize - gap / 2, y + gap / 2); dispCtx.lineTo(x + cellSize - gap / 2, y + cellSize - gap / 2); dispCtx.lineTo( x + cellSize - gap / 2 + offsetX, y + cellSize - gap / 2 + offsetY, ); dispCtx.closePath(); dispCtx.fill(); // Bottom side dispCtx.fillStyle = `rgb(${Math.max(0, pixel.r - 50)}, ${Math.max(0, pixel.g - 50)}, ${Math.max(0, pixel.b - 50)})`; dispCtx.beginPath(); dispCtx.moveTo( x + gap / 2 + offsetX, y + cellSize - gap / 2 + offsetY, ); dispCtx.lineTo(x + gap / 2, y + cellSize - gap / 2); dispCtx.lineTo(x + cellSize - gap / 2, y + cellSize - gap / 2); dispCtx.lineTo( x + cellSize - gap / 2 + offsetX, y + cellSize - gap / 2 + offsetY, ); dispCtx.closePath(); dispCtx.fill(); } // Draw top face (main cell) - brighter when elevated const brightness = 1 + elevation * 0.05; dispCtx.fillStyle = `rgb(${Math.min(255, Math.round(pixel.r * brightness))}, ${Math.min(255, Math.round(pixel.g * brightness))}, ${Math.min(255, Math.round(pixel.b * brightness))})`; dispCtx.fillRect( x + gap / 2 + offsetX, y + gap / 2 + offsetY, cellSize - gap, cellSize - gap, ); // Draw light border around top face dispCtx.strokeStyle = `rgba(${borderRGB.r}, ${borderRGB.g}, ${borderRGB.b}, ${borderOpacity + elevation * 0.008})`; dispCtx.lineWidth = 0.5; dispCtx.strokeRect( x + gap / 2 + offsetX, y + gap / 2 + offsetY, cellSize - gap, cellSize - gap, ); } } animationRef.current = requestAnimationFrame(render); }, [ gridCols, gridRows, mirror, motionSensitivity, colorMode, monoRGB, redscaleRGBStops, maxElevation, elevationSmoothing, backgroundColor, gapRatio, invertColors, darken, borderRGB, borderOpacity, ]); // Start render loop when ready useEffect(() => { if (!isReady) return; animationRef.current = requestAnimationFrame(render); return () => { cancelAnimationFrame(animationRef.current); }; }, [isReady, render]); return (
{/* Hidden video element */}
); }; export default WebcamPixelGrid;