/** * Shared types, constants, and utilities for ASCII rendering. * Used by both WebGL renderer and UI components. */ // ============= Types ============= export interface AsciiOptions { width?: number; height?: number; contrast?: number; exposure?: number; invert?: boolean; saturation?: number; gamma?: number; charSet?: CharSetKey | string; color?: boolean; dither?: number; edgeMode?: EdgeMode; autoStretch?: boolean; overlayStrength?: number; aspectMode?: AspectMode; denoise?: boolean; fontAspectRatio?: number; onProgress?: (progress: number) => void; sharpen?: number; edgeThreshold?: number; shadows?: number; highlights?: number; scanlines?: number; vignette?: number; monoColor?: string; backgroundColor?: string; } export interface AsciiResult { output: string; isHtml: boolean; width: number; height: number; } export type EdgeMode = 'none' | 'simple' | 'sobel' | 'canny'; export type CharSetKey = 'standard' | 'extended' | 'blocks' | 'minimal' | 'matrix' | 'dots' | 'shapes'; export type AspectMode = 'fit' | 'fill' | 'stretch'; export interface ImageMetadata { color_dominant?: [number, number, number]; color_palette?: [number, number, number][]; has_fine_detail?: boolean; } export interface AsciiSettings { exposure: number; contrast: number; saturation: number; gamma: number; invert: boolean; color: boolean; dither: number; denoise: boolean; edgeMode: number; overlayStrength: number; resolution: number; charSet: CharSetKey; sharpen: number; edgeThreshold: number; shadows: number; highlights: number; scanlines: number; vignette: number; monoColor: string; backgroundColor: string; } // ============= Constants ============= export const CHAR_SETS: Record = { standard: '@W%$NQ08GBR&ODHKUgSMw#Xbdp5q9C26APahk3EFVesm{}o4JZcjnuy[f1xi*7zYt(l/I\\v)T?]r><+"L;|!~:,-_.\' ', extended: '░▒▓█▀▄▌▐│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌ ', blocks: '█▓▒░ ', minimal: '#+-. ', matrix: 'ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ1234567890:.=*+-<>', dots: '⣿⣷⣯⣟⡿⢿⣻⣽⣾⣶⣦⣤⣄⣀⡀ ', shapes: '@%#*+=-:. ' }; export const ASPECT_MODES: Record = { fit: 'fit', fill: 'fill', stretch: 'stretch' }; export const EDGE_MODES: Record = { none: 'none', simple: 'simple', sobel: 'sobel', canny: 'canny' }; // Short keys for UI export const CHARSET_SHORT_MAP: Record = { STD: 'standard', EXT: 'extended', BLK: 'blocks', MIN: 'minimal', DOT: 'dots', SHP: 'shapes' }; export const CHARSET_REVERSE_MAP: Record = Object.fromEntries( Object.entries(CHARSET_SHORT_MAP).map(([k, v]) => [v, k]) ) as Record; // ============= Auto-Tune ============= export function autoTuneImage(img: HTMLImageElement, meta: ImageMetadata | null = null): Partial { if (typeof document === 'undefined') return {}; const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) return {}; const size = 100; canvas.width = size; canvas.height = size; ctx.drawImage(img, 0, 0, size, size); const imageData = ctx.getImageData(0, 0, size, size); const pixels = imageData.data; const histogram = new Array(256).fill(0); let totalLum = 0; for (let i = 0; i < pixels.length; i += 4) { const lum = Math.round(0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2]); histogram[lum]++; totalLum += lum; } const pixelCount = pixels.length / 4; const avgLum = totalLum / pixelCount; let p5: number | null = null, p95 = 255, count = 0; for (let i = 0; i < 256; i++) { count += histogram[i]; if (p5 === null && count > pixelCount * 0.05) p5 = i; if (count > pixelCount * 0.95) { p95 = i; break; } } p5 = p5 ?? 0; const midPoint = (p5 + p95) / 2; let exposure = 128 / Math.max(midPoint, 10); exposure = Math.max(0.4, Math.min(2.8, exposure)); const activeRange = p95 - p5; let contrast = 1.1; if (activeRange < 50) contrast = 2.5; else if (activeRange < 100) contrast = 1.8; else if (activeRange < 150) contrast = 1.4; let invert = false; let saturation = 1.2; let useEdgeDetection = true; if (meta) { const { color_dominant, color_palette } = meta; if (color_dominant) { const [r, g, b] = color_dominant; const domLum = 0.2126 * r + 0.7152 * g + 0.0722 * b; if (domLum > 140) { invert = true; useEdgeDetection = false; } } if (color_palette && Array.isArray(color_palette) && color_palette.length > 0) { let totalSat = 0; for (const [r, g, b] of color_palette) { const max = Math.max(r, g, b); const delta = max - Math.min(r, g, b); const s = max === 0 ? 0 : delta / max; totalSat += s; } const avgSat = totalSat / color_palette.length; if (avgSat > 0.4) saturation = 1.6; else if (avgSat < 0.1) saturation = 0.0; else saturation = 1.2; } } if (useEdgeDetection) { let edgeLumSum = 0; let edgeCount = 0; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { if (x < 5 || x >= size - 5 || y < 5 || y >= size - 5) { const i = (y * size + x) * 4; edgeLumSum += 0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2]; edgeCount++; } } } const bgLum = edgeLumSum / edgeCount; if (bgLum > 160) { invert = true; } } const gamma = avgLum < 80 ? 0.75 : avgLum > 200 ? 1.15 : 1.0; let recommendedCharSet: CharSetKey = 'standard'; let denoise = false; let dither = 0; let edgeMode: EdgeMode = 'none'; let overlayStrength = 0.3; const histogramPeaks = countHistogramPeaks(histogram, pixelCount); const isHighContrast = activeRange > 180; const isLowContrast = activeRange < 80; const noiseLevel = estimateNoiseLevel(pixels, size); const noiseThreshold = isLowContrast ? 12 : isHighContrast ? 30 : 20; const midToneCount = histogram.slice(64, 192).reduce((a, b) => a + b, 0); const hasGradients = midToneCount > pixelCount * 0.6 && histogramPeaks < 5; if (isHighContrast || (meta?.has_fine_detail)) { recommendedCharSet = 'extended'; overlayStrength = 0.2; if (noiseLevel < noiseThreshold * 0.5) { edgeMode = 'canny'; // Use Canny for high quality clean images } } else { recommendedCharSet = 'standard'; } if (isLowContrast || noiseLevel > noiseThreshold) { denoise = true; overlayStrength = isLowContrast ? 0.5 : 0.3; // Avoid complex edge detection on noisy images edgeMode = 'none'; } if (hasGradients && !denoise) { dither = 0.5; // Default dither strength } if (noiseLevel > noiseThreshold * 1.5) { dither = 0; denoise = true; } return { exposure: parseFloat(exposure.toFixed(2)), contrast, invert, gamma, saturation: parseFloat(saturation.toFixed(1)), charSet: recommendedCharSet, denoise, dither, edgeMode, overlayStrength }; } function countHistogramPeaks(histogram: number[], pixelCount: number): number { const threshold = pixelCount * 0.02; let peaks = 0; let inPeak = false; for (let i = 1; i < 255; i++) { const isPeak = histogram[i] > histogram[i - 1] && histogram[i] > histogram[i + 1]; const isSignificant = histogram[i] > threshold; if (isPeak && isSignificant && !inPeak) { peaks++; inPeak = true; } else if (histogram[i] < threshold / 2) { inPeak = false; } } return peaks; } function estimateNoiseLevel(pixels: Uint8ClampedArray, size: number): number { let totalVariance = 0; const samples = 100; for (let s = 0; s < samples; s++) { const x = Math.floor(Math.random() * (size - 2)) + 1; const y = Math.floor(Math.random() * (size - 2)) + 1; const i = (y * size + x) * 4; const center = 0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2]; const neighbors = [ (y - 1) * size + x, (y + 1) * size + x, y * size + (x - 1), y * size + (x + 1) ].map(idx => { const offset = idx * 4; return 0.2126 * pixels[offset] + 0.7152 * pixels[offset + 1] + 0.0722 * pixels[offset + 2]; }); const avgNeighbor = neighbors.reduce((a, b) => a + b, 0) / 4; totalVariance += Math.abs(center - avgNeighbor); } return totalVariance / samples; }