export interface AsciiOptions { width?: number; height?: number; contrast?: number; exposure?: number; invert?: boolean; saturation?: number; gamma?: number; charSet?: CharSetKey | string; color?: boolean; dither?: boolean; enhanceEdges?: boolean; autoStretch?: boolean; overlayStrength?: number; aspectMode?: 'fit' | 'fill' | 'stretch'; denoise?: boolean; fontAspectRatio?: number; onProgress?: (progress: number) => void; } export interface AsciiResult { output: string; isHtml: boolean; width: number; height: number; } export type CharSetKey = 'standard' | 'simple' | 'blocks' | 'minimal' | 'matrix' | 'dots' | 'ascii_extended'; export type AspectMode = 'fit' | 'fill' | 'stretch'; export const CHAR_SETS: Record = { standard: '@W%$NQ08GBR&ODHKUgSMw#Xbdp5q9C26APahk3EFVesm{}o4JZcjnuy[f1xi*7zYt(l/I\\v)T?]r><+^"L;|!~:,-_.\' ', simple: '@%#*+=-:. ', blocks: '█▓▒░ ', minimal: '#+-. ', matrix: 'ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ1234567890:.=*+-<>', dots: '⣿⣷⣯⣟⡿⢿⣻⣽⣾⣶⣦⣤⣄⣀⡀ ', ascii_extended: '░▒▓█▀▄▌▐│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌ ' }; export const ASPECT_MODES: Record = { fit: 'fit', fill: 'fill', stretch: 'stretch' }; interface ImageMetadata { color_dominant?: [number, number, number]; color_palette?: [number, number, number][]; has_fine_detail?: boolean; } export class AsciiGenerator { private ctx: CanvasRenderingContext2D | null = null; private canvas: HTMLCanvasElement | null = null; private sharpCanvas: HTMLCanvasElement | null = null; private sharpCtx: CanvasRenderingContext2D | null = null; private denoiseCanvas: HTMLCanvasElement | null = null; private denoiseCtx: CanvasRenderingContext2D | null = null; private colorData: Uint8Array | null = null; dispose(): void { this.ctx = null; this.sharpCtx = null; this.denoiseCtx = null; this.colorData = null; if (this.canvas) { this.canvas.width = 0; this.canvas.height = 0; this.canvas = null; } if (this.sharpCanvas) { this.sharpCanvas.width = 0; this.sharpCanvas.height = 0; this.sharpCanvas = null; } if (this.denoiseCanvas) { this.denoiseCanvas.width = 0; this.denoiseCanvas.height = 0; this.denoiseCanvas = null; } } async generate(imageSource: string | HTMLImageElement, options: AsciiOptions = {}): Promise { if (typeof document === 'undefined') { throw new Error('AsciiGenerator requires a browser environment.'); } const onProgress = options.onProgress ?? (() => { }); onProgress(0); const img = await this.resolveImage(imageSource); onProgress(10); const requestedWidth = options.width ?? 100; const fontAspectRatio = options.fontAspectRatio ?? 0.55; const imgRatio = this.getImageRatio(img); const aspectMode = options.aspectMode ?? 'fit'; let width: number, height: number; if (aspectMode === 'stretch') { width = requestedWidth; height = options.height ?? Math.floor(requestedWidth / 2); } else if (aspectMode === 'fill') { width = requestedWidth; const naturalHeight = Math.floor(requestedWidth / (imgRatio / fontAspectRatio)); height = options.height ?? naturalHeight; } else { width = requestedWidth; height = options.height ?? Math.floor(requestedWidth / (imgRatio / fontAspectRatio)); } let charSet: string = options.charSet ?? 'standard'; if (charSet in CHAR_SETS) { charSet = CHAR_SETS[charSet as CharSetKey]; } if (!this.canvas) { this.canvas = document.createElement('canvas'); } this.canvas.width = width; this.canvas.height = height; this.ctx = this.canvas.getContext('2d'); if (!this.sharpCanvas) { this.sharpCanvas = document.createElement('canvas'); } this.sharpCanvas.width = width; this.sharpCanvas.height = height; this.sharpCtx = this.sharpCanvas.getContext('2d'); const exposure = options.exposure ?? 1.0; const contrast = options.contrast ?? 1.0; const saturation = options.saturation ?? 1.2; const gamma = options.gamma ?? 1.0; const dither = options.dither ?? false; const enhanceEdges = options.enhanceEdges ?? false; const autoStretch = options.autoStretch !== false; const overlayStrength = options.overlayStrength ?? 0.3; const denoise = options.denoise ?? false; const colorOutput = options.color ?? false; onProgress(20); let sourceImage: HTMLImageElement | HTMLCanvasElement = img; if (denoise) { if (!this.denoiseCanvas) { this.denoiseCanvas = document.createElement('canvas'); } this.denoiseCanvas.width = width; this.denoiseCanvas.height = height; this.denoiseCtx = this.denoiseCanvas.getContext('2d'); if (this.denoiseCtx) { this.denoiseCtx.filter = 'blur(0.5px)'; this.denoiseCtx.drawImage(img, 0, 0, width, height); sourceImage = this.denoiseCanvas; } } let sx = 0, sy = 0, sw = img.width, sh = img.height; if (aspectMode === 'fill' && options.height) { const targetRatio = width / (options.height * fontAspectRatio); if (imgRatio > targetRatio) { sw = img.height * targetRatio; sx = (img.width - sw) / 2; } else { sh = img.width / targetRatio; sy = (img.height - sh) / 2; } } if (this.sharpCtx) { this.sharpCtx.filter = `brightness(${exposure}) contrast(${contrast}) saturate(${saturation})`; if (denoise && sourceImage === this.denoiseCanvas) { this.sharpCtx.drawImage(sourceImage, 0, 0, width, height); } else { this.sharpCtx.drawImage(img, sx, sy, sw, sh, 0, 0, width, height); } } if (enhanceEdges && this.sharpCtx) { this.sharpCtx.filter = 'none'; this.sharpCtx.globalCompositeOperation = 'source-over'; const edgeCanvas = document.createElement('canvas'); edgeCanvas.width = width; edgeCanvas.height = height; const edgeCtx = edgeCanvas.getContext('2d'); if (edgeCtx) { edgeCtx.filter = 'contrast(2) brightness(0.8)'; edgeCtx.drawImage(this.sharpCanvas!, 0, 0); this.sharpCtx.globalAlpha = 0.4; this.sharpCtx.globalCompositeOperation = 'multiply'; this.sharpCtx.drawImage(edgeCanvas, 0, 0); this.sharpCtx.globalCompositeOperation = 'source-over'; this.sharpCtx.globalAlpha = 1.0; } } onProgress(40); if (this.ctx && this.sharpCanvas) { this.ctx.globalAlpha = 1.0; this.ctx.drawImage(this.sharpCanvas, 0, 0); if (overlayStrength > 0) { this.ctx.globalCompositeOperation = 'overlay'; this.ctx.globalAlpha = overlayStrength; this.ctx.drawImage(this.sharpCanvas, 0, 0); this.ctx.globalCompositeOperation = 'source-over'; this.ctx.globalAlpha = 1.0; } } const imageData = this.ctx!.getImageData(0, 0, width, height); const pixels = imageData.data; onProgress(50); const lumMatrix = new Float32Array(width * height); let minLum = 1.0, maxLum = 0.0; if (colorOutput) { this.colorData = new Uint8Array(width * height * 3); } for (let i = 0; i < width * height; i++) { const offset = i * 4; const r = pixels[offset]; const g = pixels[offset + 1]; const b = pixels[offset + 2]; let lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; if (colorOutput && this.colorData) { this.colorData[i * 3] = r; this.colorData[i * 3 + 1] = g; this.colorData[i * 3 + 2] = b; } if (gamma !== 1.0) { lum = Math.pow(lum, gamma); } if (options.invert) { lum = 1 - lum; } lumMatrix[i] = lum; if (lum < minLum) minLum = lum; if (lum > maxLum) maxLum = lum; } onProgress(60); const lumRange = maxLum - minLum; if (autoStretch && lumRange > 0.01) { for (let i = 0; i < lumMatrix.length; i++) { lumMatrix[i] = (lumMatrix[i] - minLum) / lumRange; } } if (dither) { const levels = charSet.length; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const i = y * width + x; const oldVal = lumMatrix[i]; const newVal = Math.round(oldVal * (levels - 1)) / (levels - 1); lumMatrix[i] = newVal; const error = oldVal - newVal; if (x + 1 < width) lumMatrix[i + 1] += error * 7 / 16; if (y + 1 < height) { if (x > 0) lumMatrix[(y + 1) * width + (x - 1)] += error * 3 / 16; lumMatrix[(y + 1) * width + x] += error * 5 / 16; if (x + 1 < width) lumMatrix[(y + 1) * width + (x + 1)] += error * 1 / 16; } } } } onProgress(80); let output = ''; if (colorOutput && this.colorData) { for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const i = y * width + x; const brightness = Math.max(0, Math.min(1, lumMatrix[i])); const charIndex = Math.floor(brightness * (charSet.length - 1)); const safeIndex = Math.max(0, Math.min(charSet.length - 1, charIndex)); const char = charSet[safeIndex]; const r = this.colorData[i * 3]; const g = this.colorData[i * 3 + 1]; const b = this.colorData[i * 3 + 2]; const safeChar = char === '<' ? '<' : char === '>' ? '>' : char === '&' ? '&' : char; output += `${safeChar}`; } output += '\n'; } } else { for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const brightness = Math.max(0, Math.min(1, lumMatrix[y * width + x])); const charIndex = Math.floor(brightness * (charSet.length - 1)); const safeIndex = Math.max(0, Math.min(charSet.length - 1, charIndex)); output += charSet[safeIndex]; } output += '\n'; } } onProgress(100); if (colorOutput) { return { output, isHtml: true, width, height }; } return output; } private getImageRatio(img: HTMLImageElement): number { if (img.width && img.height) { return img.width / img.height; } return 1; } private resolveImage(src: string | HTMLImageElement): Promise { return new Promise((resolve, reject) => { if (src instanceof HTMLImageElement) { if (src.complete) return resolve(src); src.onload = () => resolve(src); src.onerror = reject; return; } const img = new Image(); img.crossOrigin = 'Anonymous'; img.src = src; img.onload = () => resolve(img); img.onerror = () => reject(new Error('Failed to load image')); }); } } export async function imageToAscii(imageSource: string | HTMLImageElement, options: AsciiOptions = {}): Promise { const generator = new AsciiGenerator(); return generator.generate(imageSource, options); } 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 : 1.0; let recommendedCharSet: CharSetKey = 'standard'; let denoise = false; let enhanceEdges = false; let overlayStrength = 0.3; const histogramPeaks = countHistogramPeaks(histogram, pixelCount); const isHighContrast = activeRange > 180; const isLowContrast = activeRange < 80; const isBimodal = histogramPeaks <= 3; if (isBimodal && activeRange > 150) { recommendedCharSet = 'minimal'; enhanceEdges = true; overlayStrength = 0.1; } else if (isHighContrast) { recommendedCharSet = 'blocks'; overlayStrength = 0.2; } else if (isLowContrast) { recommendedCharSet = 'simple'; denoise = true; overlayStrength = 0.5; } else if (activeRange > 100 && activeRange <= 180) { recommendedCharSet = 'standard'; const noiseLevel = estimateNoiseLevel(pixels, size); if (noiseLevel > 20) { denoise = true; } } if (meta?.has_fine_detail) { recommendedCharSet = 'dots'; } return { exposure: parseFloat(exposure.toFixed(2)), contrast, invert, gamma, saturation: parseFloat(saturation.toFixed(1)), charSet: recommendedCharSet, denoise, enhanceEdges, 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; }