diff --git a/package-lock.json b/package-lock.json index 8824f4c..d281006 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@astrojs/node": "^9.5.2", "astro": "^5.17.1", "gifuct-js": "^2.1.2", + "pngjs": "^7.0.0", "typescript": "^5.9.3" } }, @@ -4387,6 +4388,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", diff --git a/package.json b/package.json index 74784d4..a634541 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@astrojs/node": "^9.5.2", "astro": "^5.17.1", "gifuct-js": "^2.1.2", + "pngjs": "^7.0.0", "typescript": "^5.9.3" } } diff --git a/public/assets/blue-noise.png b/public/assets/blue-noise.png new file mode 100644 index 0000000..3760047 Binary files /dev/null and b/public/assets/blue-noise.png differ diff --git a/src/scripts/ascii-controller.ts b/src/scripts/ascii-controller.ts new file mode 100644 index 0000000..1767932 --- /dev/null +++ b/src/scripts/ascii-controller.ts @@ -0,0 +1,463 @@ +/** + * ASCII Renderer Controller + * Manages state, render loop, and grid calculations. + */ + +import { CHAR_SETS, type CharSetKey, type AsciiOptions } from './ascii-shared'; +import { WebGLAsciiRenderer, type RenderOptions } from './webgl-ascii'; + +// ============= Types ============= + +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; +} + +export interface GridCache { + widthCols: number; + heightRows: number; + imgEl: HTMLImageElement | null; +} + +export interface ZoomState { + zoom: number; + zoomCenter: { x: number; y: number }; + mousePos: { x: number; y: number }; + showMagnifier: boolean; +} + +export type RenderDirtyFlags = 'texture' | 'grid' | 'uniforms' | 'all'; + +// ============= Controller ============= + +export class AsciiController { + // DOM + private canvas: HTMLCanvasElement; + private asciiResult: HTMLPreElement; + private loadingIndicator: HTMLDivElement; + + // Renderer + private renderer: WebGLAsciiRenderer | null = null; + + // State + private settings: AsciiSettings; + private detectedSettings: Partial = {}; + private invertMode: 'auto' | 'on' | 'off' = 'auto'; + private detectedInvert = false; + private currentImgUrl: string | null = null; + + // Render loop + private dirtyTexture = false; + private dirtyGrid = false; + private dirtyUniforms = false; + private cachedGrid: GridCache = { widthCols: 0, heightRows: 0, imgEl: null }; + private animFrameId: number | null = null; + + // Zoom + private zoomState: ZoomState = { + zoom: 1.0, + zoomCenter: { x: 0.5, y: 0.5 }, + mousePos: { x: -1, y: -1 }, + showMagnifier: false + }; + + // Callbacks + private onSettingsChange?: () => void; + + constructor(canvas: HTMLCanvasElement, asciiResult: HTMLPreElement, loadingIndicator: HTMLDivElement) { + this.canvas = canvas; + this.asciiResult = asciiResult; + this.loadingIndicator = loadingIndicator; + + this.settings = this.getDefaultSettings(); + + try { + this.renderer = new WebGLAsciiRenderer(canvas); + } catch (e) { + console.error('WebGL not available:', e); + throw new Error('WebGL is required for this application'); + } + + this.startRenderLoop(); + } + + private getDefaultSettings(): AsciiSettings { + return { + exposure: 1.0, + contrast: 1.0, + saturation: 1.2, + gamma: 1.0, + invert: false, + color: false, + dither: 0, + denoise: false, + edgeMode: 0, + overlayStrength: 0.3, + resolution: 1.0, + charSet: 'standard' + }; + } + + // ============= Getters/Setters ============= + + getSettings(): AsciiSettings { + return { ...this.settings }; + } + + getSetting(key: K): AsciiSettings[K] { + return this.settings[key]; + } + + setSetting(key: K, value: AsciiSettings[K]): void { + if (this.settings[key] === value) return; // Prevent redundant updates and recursion + + this.settings[key] = value; + if (key === 'resolution') { + this.calculateGrid().then(() => this.requestRender('grid')); + } else { + this.requestRender('uniforms'); + } + this.onSettingsChange?.(); + } + + getInvertMode(): 'auto' | 'on' | 'off' { + return this.invertMode; + } + + setInvertMode(mode: 'auto' | 'on' | 'off'): void { + this.invertMode = mode; + if (mode === 'auto') { + this.settings.invert = this.detectedInvert; + } else { + this.settings.invert = mode === 'on'; + } + this.requestRender('uniforms'); + this.onSettingsChange?.(); + } + + cycleInvertMode(): void { + if (this.invertMode === 'auto') { + this.setInvertMode('on'); + } else if (this.invertMode === 'on') { + this.setInvertMode('off'); + } else { + this.setInvertMode('auto'); + } + } + + cycleCharSet(): void { + const keys = Object.keys(CHAR_SETS) as CharSetKey[]; + const idx = keys.indexOf(this.settings.charSet); + const nextIdx = (idx + 1) % keys.length; + this.setSetting('charSet', keys[nextIdx]); + } + + getZoomState(): ZoomState { + return { ...this.zoomState }; + } + + getDetectedInvert(): boolean { + return this.detectedInvert; + } + + getCachedGrid(): GridCache { + return { ...this.cachedGrid }; + } + + getCurrentImageUrl(): string | null { + return this.currentImgUrl; + } + + // ============= Callbacks ============= + + onSettingsChanged(callback: () => void): void { + this.onSettingsChange = callback; + } + + // ============= Image Loading ============= + + setCurrentImage(url: string, suggestions: Partial): void { + this.currentImgUrl = url; + + // Reset zoom + this.zoomState = { + zoom: 1.0, + zoomCenter: { x: 0.5, y: 0.5 }, + mousePos: { x: -1, y: -1 }, + showMagnifier: false + }; + + // Apply auto-detected settings + this.invertMode = 'auto'; + this.detectedInvert = suggestions.invert ?? false; + + // Validate charSet - ensure it's a valid CharSetKey + const validCharSet = this.isValidCharSet(suggestions.charSet) + ? suggestions.charSet + : this.settings.charSet; + + this.detectedSettings = { + ...suggestions, + charSet: validCharSet, + edgeMode: this.mapEdgeMode(suggestions.edgeMode) + } as Partial; + + this.settings = { + ...this.settings, + exposure: suggestions.exposure ?? this.settings.exposure, + contrast: suggestions.contrast ?? this.settings.contrast, + saturation: suggestions.saturation ?? this.settings.saturation, + gamma: suggestions.gamma ?? this.settings.gamma, + invert: this.detectedInvert, + dither: suggestions.dither ?? this.settings.dither, + denoise: suggestions.denoise ?? this.settings.denoise, + edgeMode: suggestions.edgeMode ? this.mapEdgeMode(suggestions.edgeMode) : this.settings.edgeMode, + overlayStrength: suggestions.overlayStrength ?? this.settings.overlayStrength, + charSet: validCharSet, + resolution: this.settings.resolution, + color: this.settings.color + }; + + this.onSettingsChange?.(); + } + + private mapEdgeMode(mode: string | undefined): number { + if (!mode) return 0; + switch (mode) { + case 'simple': return 1; + case 'sobel': return 2; + case 'canny': return 3; + default: return 0; + } + } + + private isValidCharSet(value: string | CharSetKey | undefined): value is CharSetKey { + if (!value) return false; + return Object.keys(CHAR_SETS).includes(value); + } + + resetToAutoSettings(): void { + if (Object.keys(this.detectedSettings).length > 0) { + this.invertMode = 'auto'; + this.detectedInvert = this.detectedSettings.invert ?? false; + this.settings = { + ...this.settings, + ...this.detectedSettings, + resolution: this.settings.resolution, + color: false + }; + this.settings.invert = this.detectedInvert; + + this.calculateGrid().then(() => this.requestRender('all')); + this.onSettingsChange?.(); + } + } + + // ============= Rendering ============= + + requestRender(type: RenderDirtyFlags): void { + if (type === 'all') { + this.dirtyTexture = true; + this.dirtyGrid = true; + this.dirtyUniforms = true; + } else if (type === 'texture') { + this.dirtyTexture = true; + } else if (type === 'grid') { + this.dirtyGrid = true; + } else if (type === 'uniforms') { + this.dirtyUniforms = true; + } + } + + async calculateGrid(): Promise { + if (!this.currentImgUrl) return; + + const fontAspectRatio = 0.55; + const marginRatio = 0.2; + const screenW = window.innerWidth; + const availW = screenW * (1 - marginRatio); + + let widthCols = Math.floor(availW / 6); + widthCols = Math.floor(widthCols * this.settings.resolution); + widthCols = Math.max(10, Math.min(1000, widthCols)); + + const imgEl = await this.resolveImage(this.currentImgUrl); + const imgRatio = imgEl.width / imgEl.height; + const heightRows = widthCols / (imgRatio / fontAspectRatio); + + this.cachedGrid = { widthCols, heightRows, imgEl }; + return this.cachedGrid; + } + + private startRenderLoop(): void { + const loop = () => { + this.renderFrame(); + this.animFrameId = requestAnimationFrame(loop); + }; + this.animFrameId = requestAnimationFrame(loop); + } + + private renderFrame(): void { + if (!this.renderer || !this.cachedGrid.imgEl) return; + + const charSetContent = CHAR_SETS[this.settings.charSet] || CHAR_SETS.standard; + + if (this.dirtyTexture || this.dirtyGrid || this.dirtyUniforms) { + if (this.dirtyTexture) { + this.renderer.updateTexture(this.cachedGrid.imgEl); + } + + if (this.dirtyGrid) { + this.updateCanvasSize(); + this.renderer.updateGrid( + this.cachedGrid.widthCols, + Math.floor(this.cachedGrid.heightRows) + ); + } + + if (this.dirtyUniforms || this.dirtyGrid) { + this.renderer.updateUniforms({ + width: this.cachedGrid.widthCols, + height: Math.floor(this.cachedGrid.heightRows), + charSetContent, + ...this.settings, + zoom: this.zoomState.zoom, + zoomCenter: this.zoomState.zoomCenter, + mousePos: this.zoomState.mousePos, + showMagnifier: this.zoomState.showMagnifier, + magnifierRadius: 0.15, + magnifierZoom: 2.5 + } as RenderOptions); + } + + this.renderer.draw(); + + this.dirtyTexture = false; + this.dirtyGrid = false; + this.dirtyUniforms = false; + } + } + + private updateCanvasSize(): void { + const fontAspectRatio = 0.55; + const gridAspect = (this.cachedGrid.widthCols * fontAspectRatio) / this.cachedGrid.heightRows; + const screenW = window.innerWidth; + const screenH = window.innerHeight; + const maxW = screenW * 0.95; + const maxH = screenH * 0.95; + + let finalW: number, finalH: number; + if (gridAspect > maxW / maxH) { + finalW = maxW; + finalH = maxW / gridAspect; + } else { + finalH = maxH; + finalW = maxH * gridAspect; + } + + this.canvas.style.width = `${finalW}px`; + this.canvas.style.height = `${finalH}px`; + const dpr = window.devicePixelRatio || 1; + this.canvas.width = finalW * dpr; + this.canvas.height = finalH * dpr; + } + + async generate(): Promise { + await this.calculateGrid(); + this.asciiResult.style.display = 'none'; + this.canvas.style.display = 'block'; + this.canvas.style.opacity = '1'; + this.requestRender('all'); + } + + // ============= Zoom ============= + + handleWheel(e: WheelEvent): void { + const rect = this.canvas.getBoundingClientRect(); + const mx = (e.clientX - rect.left) / rect.width; + const my = (e.clientY - rect.top) / rect.height; + + const delta = -e.deltaY; + const factor = delta > 0 ? 1.1 : 0.9; + const oldZoom = this.zoomState.zoom; + + this.zoomState.zoom = Math.min(Math.max(this.zoomState.zoom * factor, 1.0), 10.0); + + if (this.zoomState.zoom === 1.0) { + this.zoomState.zoomCenter = { x: 0.5, y: 0.5 }; + } else if (oldZoom !== this.zoomState.zoom) { + const imgX = (mx - this.zoomState.zoomCenter.x) / oldZoom + this.zoomState.zoomCenter.x; + const imgY = (my - this.zoomState.zoomCenter.y) / oldZoom + this.zoomState.zoomCenter.y; + this.zoomState.zoomCenter.x = (imgX - mx / this.zoomState.zoom) / (1 - 1 / this.zoomState.zoom); + this.zoomState.zoomCenter.y = (imgY - my / this.zoomState.zoom) / (1 - 1 / this.zoomState.zoom); + } + + this.requestRender('uniforms'); + } + + handleMouseMove(e: MouseEvent): void { + const rect = this.canvas.getBoundingClientRect(); + const mx = (e.clientX - rect.left) / rect.width; + const my = (e.clientY - rect.top) / rect.height; + + this.zoomState.mousePos = { x: mx, y: my }; + const wasShowing = this.zoomState.showMagnifier; + this.zoomState.showMagnifier = mx >= 0 && mx <= 1 && my >= 0 && my <= 1; + + if (this.zoomState.showMagnifier || wasShowing) { + this.requestRender('uniforms'); + } + } + + handleMouseLeave(): void { + if (this.zoomState.showMagnifier) { + this.zoomState.showMagnifier = false; + this.requestRender('uniforms'); + } + } + + // ============= Utilities ============= + + private resolveImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = 'Anonymous'; + img.src = src; + img.onload = () => resolve(img); + img.onerror = reject; + }); + } + + showLoading(message: string): void { + this.loadingIndicator.style.display = 'block'; + this.asciiResult.textContent = message; + this.asciiResult.style.opacity = '0.5'; + } + + hideLoading(): void { + this.loadingIndicator.style.display = 'none'; + this.asciiResult.style.opacity = '1'; + } + + getCanvas(): HTMLCanvasElement { + return this.canvas; + } + + dispose(): void { + if (this.animFrameId !== null) { + cancelAnimationFrame(this.animFrameId); + } + this.renderer?.dispose(); + this.renderer = null; + } +} diff --git a/src/scripts/ascii-shared.ts b/src/scripts/ascii-shared.ts new file mode 100644 index 0000000..e9ca76a --- /dev/null +++ b/src/scripts/ascii-shared.ts @@ -0,0 +1,284 @@ +/** + * 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; +} + +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; +} + +// ============= 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; +} diff --git a/src/scripts/image-queue.ts b/src/scripts/image-queue.ts new file mode 100644 index 0000000..4c1adb0 --- /dev/null +++ b/src/scripts/image-queue.ts @@ -0,0 +1,138 @@ +/** + * Image Queue Manager + * Handles prefetching and buffering of anime images. + */ + +import { fetchRandomAnimeImage, loadSingleImage } from './anime-api'; +import { autoTuneImage, type AsciiOptions, type ImageMetadata } from './ascii-shared'; + +// ============= Types ============= + +export interface QueuedImage { + url: string; + imgElement: HTMLImageElement; + meta: ImageMetadata | null; + suggestions: Partial; +} + +// ============= Queue Manager ============= + +export class ImageQueue { + private queue: QueuedImage[] = []; + private isFetching = false; + private maxSize: number; + private onQueueUpdate?: () => void; + + private isDisposed = false; + + constructor(maxSize = 2) { + this.maxSize = maxSize; + } + + // ============= Public API ============= + + getLength(): number { + return this.queue.length; + } + + onUpdate(callback: () => void): void { + this.onQueueUpdate = callback; + } + + /** + * Pop the next image from the queue. + * Returns null if queue is empty. + */ + pop(): QueuedImage | null { + if (this.isDisposed) return null; + const item = this.queue.shift() ?? null; + this.onQueueUpdate?.(); + // Trigger background refill + this.ensureFilled(); + return item; + } + + /** + * Fetch a new image directly (bypasses queue). + * Used when queue is empty and we need an image immediately. + */ + async fetchDirect(): Promise { + if (this.isDisposed) throw new Error("Queue disposed"); + const data = await fetchRandomAnimeImage(); + const img = await loadSingleImage(data.url); + const suggestions = autoTuneImage(img, data.meta); + + return { + url: data.url, + imgElement: img, + meta: data.meta, + suggestions + }; + } + + /** + * Start filling the queue in the background. + */ + async ensureFilled(): Promise { + if (this.isDisposed) return; + + while (this.queue.length < this.maxSize && !this.isDisposed) { + await this.prefetchOne(); + // Small delay between fetches to avoid rate limiting + if (!this.isDisposed) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + } + + /** + * Prefetch a single image and add to queue. + */ + async prefetchOne(): Promise { + if (this.isFetching || this.queue.length >= this.maxSize || this.isDisposed) return; + if (typeof document !== 'undefined' && document.hidden) return; + + this.isFetching = true; + + try { + const data = await fetchRandomAnimeImage(); + // Check disposal again after async op + if (this.isDisposed) return; + + const img = await loadSingleImage(data.url); + if (this.isDisposed) return; + + const suggestions = autoTuneImage(img, data.meta); + + this.queue.push({ + url: data.url, + imgElement: img, + meta: data.meta, + suggestions + }); + + this.onQueueUpdate?.(); + } catch (e) { + console.error('Failed to prefetch image:', e); + } finally { + this.isFetching = false; + } + } + + /** + * Clear and dispose the queue. + */ + dispose(): void { + this.isDisposed = true; + this.queue = []; + this.onQueueUpdate = undefined; + } + + /** + * Clear the queue (legacy support, prefers dispose). + */ + clear(): void { + this.queue = []; + this.onQueueUpdate?.(); + } +} diff --git a/src/scripts/ui-bindings.ts b/src/scripts/ui-bindings.ts new file mode 100644 index 0000000..804d7e6 --- /dev/null +++ b/src/scripts/ui-bindings.ts @@ -0,0 +1,430 @@ +/** + * UI Bindings + * Event listeners, keyboard shortcuts, and UI synchronization. + */ + +import { CHARSET_SHORT_MAP, CHARSET_REVERSE_MAP } from './ascii-shared'; +import type { AsciiController, AsciiSettings } from './ascii-controller'; +import type { ImageQueue } from './image-queue'; + +// ============= Window Extensions ============= + +declare global { + interface Window { + updateToggleState?: (id: string, checked: boolean) => void; + updateSegmentValue?: (id: string, value: string) => void; + __ASCII_APP__?: { + controller: AsciiController; + queue: ImageQueue; + ui: UIBindings; + dispose: () => void; + }; + } +} + +// ============= UI Manager ============= + +export class UIBindings { + private controller: AsciiController; + private queue: ImageQueue; + private loadNewImageFn: () => Promise; + private isUpdatingUI = false; + + + // Event Handlers implementation references + private sliderHandlers: Map void> = new Map(); + private wheelHandlers: Map void> = new Map(); + private toggleHandler: ((e: Event) => void) | null = null; + private segmentHandlers: Map void> = new Map(); + private buttonHandlers: Map void> = new Map(); + private keydownHandler: ((e: KeyboardEvent) => void) | null = null; + private zoomHandlers: { + wheel?: (e: Event) => void; + move?: (e: Event) => void; + leave?: (e: Event) => void; + } = {}; + private resizeHandler: (() => void) | null = null; + + private queueInterval: number | null = null; + + constructor( + controller: AsciiController, + queue: ImageQueue, + loadNewImage: () => Promise + ) { + this.controller = controller; + this.queue = queue; + this.loadNewImageFn = loadNewImage; + } + + // ============= Setup ============= + + init(): void { + this.setupSliders(); + this.setupToggles(); + this.setupSegments(); + this.setupButtons(); + this.setupKeyboard(); + this.setupZoom(); + this.setupResize(); + + // Periodic queue update + this.queueInterval = window.setInterval(() => this.updateQueueDisplay(), 1000); + } + + dispose(): void { + // Clear interval + if (this.queueInterval !== null) { + clearInterval(this.queueInterval); + this.queueInterval = null; + } + + // Cleanup Sliders + const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither'] as const; + sliderIds.forEach(id => { + const input = document.getElementById(id) as HTMLInputElement | null; + const handler = this.sliderHandlers.get(id); + const wheelHandler = this.wheelHandlers.get(id); + if (input) { + if (handler) input.removeEventListener('input', handler); + if (wheelHandler) input.removeEventListener('wheel', wheelHandler as any); + } + }); + this.sliderHandlers.clear(); + this.wheelHandlers.clear(); + + // Cleanup Toggles + if (this.toggleHandler) { + document.body.removeEventListener('toggle-change', this.toggleHandler); + this.toggleHandler = null; + } + + // Cleanup Segments + ['segment-invert', 'segment-charset'].forEach(id => { + const el = document.getElementById(id); + const handler = this.segmentHandlers.get(id); + if (el && handler) { + el.removeEventListener('segment-change', handler); + } + }); + this.segmentHandlers.clear(); + + // Cleanup Buttons + ['btn-reset', 'btn-next'].forEach(id => { + const el = document.getElementById(id); + const handler = this.buttonHandlers.get(id); + if (el && handler) { + el.removeEventListener('click', handler); + } + }); + this.buttonHandlers.clear(); + + // Cleanup Keyboard + if (this.keydownHandler) { + document.removeEventListener('keydown', this.keydownHandler); + this.keydownHandler = null; + } + + // Cleanup Zoom + const heroWrapper = document.querySelector('.hero-wrapper'); + if (heroWrapper) { + if (this.zoomHandlers.wheel) heroWrapper.removeEventListener('wheel', this.zoomHandlers.wheel); + if (this.zoomHandlers.move) heroWrapper.removeEventListener('mousemove', this.zoomHandlers.move); + if (this.zoomHandlers.leave) heroWrapper.removeEventListener('mouseleave', this.zoomHandlers.leave); + } + this.zoomHandlers = {}; + + // Cleanup Resize + if (this.resizeHandler) { + window.removeEventListener('resize', this.resizeHandler); + this.resizeHandler = null; + } + } + + // ============= Sliders ============= + + private setupSliders(): void { + const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither'] as const; + + sliderIds.forEach(id => { + const input = document.getElementById(id) as HTMLInputElement | null; + if (input) { + // Change listener + const handler = () => { + if (this.isUpdatingUI) return; + const value = parseFloat(input.value); + this.controller.setSetting(id as keyof AsciiSettings, value as any); + }; + this.sliderHandlers.set(id, handler); + input.addEventListener('input', handler); + + // Wheel listener + const wheelHandler = (e: WheelEvent) => { + // Prevent page scroll and zoom + e.preventDefault(); + e.stopPropagation(); + + const step = parseFloat(input.step) || 0.01; + // Standardize wheel delta + const direction = e.deltaY > 0 ? -1 : 1; + const currentVal = parseFloat(input.value); + const min = parseFloat(input.min) || 0; + const max = parseFloat(input.max) || 100; + + const newVal = Math.min(max, Math.max(min, currentVal + direction * step)); + + if (Math.abs(newVal - currentVal) > 0.0001) { + input.value = newVal.toString(); + input.dispatchEvent(new Event('input')); + } + }; + this.wheelHandlers.set(id, wheelHandler); + input.addEventListener('wheel', wheelHandler as any, { passive: false }); + } + }); + } + + // ============= Toggles ============= + + private setupToggles(): void { + this.toggleHandler = (e: Event) => { + const target = e.target as HTMLElement; + if (!target) return; + + const toggleId = target.id; + const checked = (e as CustomEvent).detail?.checked; + + switch (toggleId) { + case 'toggle-color': + this.controller.setSetting('color', checked); + break; + + case 'toggle-denoise': + this.controller.setSetting('denoise', checked); + break; + } + }; + document.body.addEventListener('toggle-change', this.toggleHandler); + } + + // ============= Segments ============= + + private setupSegments(): void { + // Invert Segment + const invertEl = document.getElementById('segment-invert'); + if (invertEl) { + const handler = (e: Event) => { + const value = (e as CustomEvent).detail.value; + if (value === 'AUTO') { + this.controller.setInvertMode('auto'); + } else if (value === 'ON') { + this.controller.setInvertMode('on'); + } else { + this.controller.setInvertMode('off'); + } + }; + this.segmentHandlers.set('segment-invert', handler); + invertEl.addEventListener('segment-change', handler); + } + + // Charset Segment + const charsetEl = document.getElementById('segment-charset'); + if (charsetEl) { + const handler = (e: Event) => { + const shortKey = (e as CustomEvent).detail.value; + const charSet = CHARSET_SHORT_MAP[shortKey] || 'standard'; + this.controller.setSetting('charSet', charSet); + }; + this.segmentHandlers.set('segment-charset', handler); + charsetEl.addEventListener('segment-change', handler); + } + + // Edge Mode Segment + const edgeEl = document.getElementById('segment-edge'); + if (edgeEl) { + const handler = (e: Event) => { + const val = (e as CustomEvent).detail.value; + let mode = 0; + switch (val) { + case 'SPL': mode = 1; break; + case 'SOB': mode = 2; break; + case 'CNY': mode = 3; break; + default: mode = 0; break; + } + this.controller.setSetting('edgeMode', mode); + }; + this.segmentHandlers.set('segment-edge', handler); + edgeEl.addEventListener('segment-change', handler); + } + } + + // ============= Buttons ============= + + private setupButtons(): void { + // Reset + const btnReset = document.getElementById('btn-reset'); + if (btnReset) { + const handler = (e: Event) => { + e.stopPropagation(); + this.controller.resetToAutoSettings(); + }; + this.buttonHandlers.set('btn-reset', handler); + btnReset.addEventListener('click', handler); + } + + + + // Next + const btnNext = document.getElementById('btn-next'); + if (btnNext) { + const handler = (e: Event) => { + e.stopPropagation(); + this.loadNewImageFn(); + }; + this.buttonHandlers.set('btn-next', handler); + btnNext.addEventListener('click', handler); + } + } + + // ============= Keyboard ============= + + private setupKeyboard(): void { + this.keydownHandler = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return; + } + + + + switch (e.key.toLowerCase()) { + case 'n': + this.loadNewImageFn(); + break; + case 'r': + this.controller.resetToAutoSettings(); + break; + case 'i': + this.controller.cycleInvertMode(); + this.updateUI(); + break; + case 'c': + this.controller.setSetting('color', !this.controller.getSetting('color')); + this.updateUI(); + break; + case 'd': + // Toggle dither strength between 0 and 0.5 + const currentDither = this.controller.getSetting('dither'); + this.controller.setSetting('dither', currentDither > 0 ? 0 : 0.5); + this.updateUI(); + break; + case 'e': + // Cycle edge modes: 0 -> 1 -> 2 -> 3 -> 0 + const currentMode = this.controller.getSetting('edgeMode'); + const nextMode = (currentMode + 1) % 4; + this.controller.setSetting('edgeMode', nextMode); + this.updateUI(); + break; + case 's': + this.controller.cycleCharSet(); + this.updateUI(); + break; + } + }; + document.addEventListener('keydown', this.keydownHandler); + } + + // ============= Zoom ============= + + private setupZoom(): void { + const heroWrapper = document.querySelector('.hero-wrapper'); + if (!heroWrapper) return; + + this.zoomHandlers.wheel = (e: Event) => { + const we = e as WheelEvent; + if ((we.target as HTMLElement).closest('#tui-controls')) return; + + we.preventDefault(); + this.controller.handleWheel(we); + }; + // Use passive: false to allow preventDefault + heroWrapper.addEventListener('wheel', this.zoomHandlers.wheel, { passive: false }); + + this.zoomHandlers.move = (e: Event) => { + this.controller.handleMouseMove(e as MouseEvent); + }; + heroWrapper.addEventListener('mousemove', this.zoomHandlers.move); + + this.zoomHandlers.leave = () => { + this.controller.handleMouseLeave(); + }; + heroWrapper.addEventListener('mouseleave', this.zoomHandlers.leave); + } + + // ============= Resize ============= + + private setupResize(): void { + let resizeTimeout: number | undefined; + this.resizeHandler = () => { + window.clearTimeout(resizeTimeout); + resizeTimeout = window.setTimeout(() => this.controller.generate(), 200); + }; + window.addEventListener('resize', this.resizeHandler); + } + + // ============= UI Sync ============= + + updateUI(): void { + if (this.isUpdatingUI) return; + this.isUpdatingUI = true; + + const settings = this.controller.getSettings(); + + // Update sliders + const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither'] as const; + sliderIds.forEach(id => { + const input = document.getElementById(id) as HTMLInputElement | null; + if (input && settings[id] !== undefined) { + const val = parseFloat(input.value); + // Use a small epsilon for float comparison to avoid jitter + if (Math.abs(val - settings[id]) > 0.0001) { + input.value = String(settings[id]); + input.dispatchEvent(new Event('input')); + } + } + }); + + // Update toggles + window.updateToggleState?.('toggle-color', settings.color); + window.updateToggleState?.('toggle-denoise', settings.denoise); + + // Update segments + const invertMode = this.controller.getInvertMode(); + const invertValue = invertMode === 'auto' ? 'AUTO' : settings.invert ? 'ON' : 'OFF'; + window.updateSegmentValue?.('segment-invert', invertValue); + + const charSetShort = CHARSET_REVERSE_MAP[settings.charSet] || 'STD'; + window.updateSegmentValue?.('segment-charset', charSetShort); + + let edgeShort = 'OFF'; + switch (settings.edgeMode) { + case 1: edgeShort = 'SPL'; break; + case 2: edgeShort = 'SOB'; break; + case 3: edgeShort = 'CNY'; break; + default: edgeShort = 'OFF'; break; + } + window.updateSegmentValue?.('segment-edge', edgeShort); + + this.updateQueueDisplay(); + + this.isUpdatingUI = false; + } + + updateQueueDisplay(): void { + const queueEl = document.getElementById('val-queue'); + if (queueEl) { + queueEl.textContent = this.queue.getLength().toString(); + } + } + + +} diff --git a/src/scripts/webgl-ascii.ts b/src/scripts/webgl-ascii.ts index 7fd7851..a231139 100644 --- a/src/scripts/webgl-ascii.ts +++ b/src/scripts/webgl-ascii.ts @@ -12,8 +12,8 @@ export interface RenderOptions { invert: boolean; color: boolean; overlayStrength?: number; - enhanceEdges?: boolean; - dither?: boolean; + edgeMode?: number; // 0=none, 1=simple, 2=sobel, 3=canny + dither?: number; denoise?: boolean; zoom?: number; zoomCenter?: { x: number; y: number }; @@ -36,7 +36,7 @@ export class WebGLAsciiRenderer { private gl: WebGLRenderingContext; private program: WebGLProgram | null; - private textures: { image?: WebGLTexture; atlas?: WebGLTexture }; + private textures: { image?: WebGLTexture; atlas?: WebGLTexture; blueNoise?: WebGLTexture }; private buffers: { position?: WebGLBuffer; texCoord?: WebGLBuffer }; private charAtlas: { width: number; height: number; charWidth: number; charHeight: number; count: number } | null; private charSet: string; @@ -61,6 +61,7 @@ export class WebGLAsciiRenderer { this.fontFamily = "'JetBrains Mono', monospace"; this.init(); + this.loadBlueNoiseTexture(); } init() { @@ -84,7 +85,9 @@ export class WebGLAsciiRenderer { uniform sampler2D u_image; uniform sampler2D u_atlas; + uniform sampler2D u_blueNoise; uniform float u_charCount; + uniform vec2 u_charSizeUV; // Size of one char in UV space (width/texWidth, height/texHeight) uniform vec2 u_gridSize; // cols, rows uniform vec2 u_texSize; // atlas size @@ -96,7 +99,9 @@ export class WebGLAsciiRenderer { uniform bool u_invert; uniform bool u_color; uniform float u_overlayStrength; - uniform bool u_enhanceEdges; + uniform int u_edgeMode; // 0=none, 1=simple, 2=sobel, 3=canny + uniform float u_dither; // Dither strength 0.0 - 1.0 + uniform bool u_denoise; // Zoom & Magnifier uniform float u_zoom; @@ -107,6 +112,15 @@ export class WebGLAsciiRenderer { uniform bool u_showMagnifier; uniform float u_aspect; + // Blue Noise Dithering + float blueNoise(vec2 pos) { + // Map screen coordinates to texture coordinates (64x64 texture) + vec2 noiseUV = pos / 64.0; + float noiseVal = texture2D(u_blueNoise, noiseUV).r; + // Shift range to -0.5 to 0.5 for dither offset + return noiseVal - 0.5; + } + vec3 adjust(vec3 color) { // Exposure color *= u_exposure; @@ -124,6 +138,59 @@ export class WebGLAsciiRenderer { return clamp(color, 0.0, 1.0); } + // Function to get average color from a cell using 5 samples (center + corners) + vec3 getAverageColor(vec2 cellCenterUV, vec2 cellSize) { + vec3 sum = vec3(0.0); + vec2 halfSize = cellSize * 0.25; // Sample halfway to the edge + + // Center + sum += texture2D(u_image, cellCenterUV).rgb; + + // Corners + sum += texture2D(u_image, cellCenterUV + vec2(-halfSize.x, -halfSize.y)).rgb; + sum += texture2D(u_image, cellCenterUV + vec2(halfSize.x, -halfSize.y)).rgb; + sum += texture2D(u_image, cellCenterUV + vec2(-halfSize.x, halfSize.y)).rgb; + sum += texture2D(u_image, cellCenterUV + vec2(halfSize.x, halfSize.y)).rgb; + + return sum / 5.0; + } + + // Sobel Filter - returns gradient magnitude and direction (approx) + vec2 sobelFilter(vec2 uv, vec2 cellSize) { + vec3 t = texture2D(u_image, uv + vec2(0.0, -cellSize.y)).rgb; + vec3 b = texture2D(u_image, uv + vec2(0.0, cellSize.y)).rgb; + vec3 l = texture2D(u_image, uv + vec2(-cellSize.x, 0.0)).rgb; + vec3 r = texture2D(u_image, uv + vec2(cellSize.x, 0.0)).rgb; + vec3 tl = texture2D(u_image, uv + vec2(-cellSize.x, -cellSize.y)).rgb; + vec3 tr = texture2D(u_image, uv + vec2(cellSize.x, -cellSize.y)).rgb; + vec3 bl = texture2D(u_image, uv + vec2(-cellSize.x, cellSize.y)).rgb; + vec3 br = texture2D(u_image, uv + vec2(cellSize.x, cellSize.y)).rgb; + + // Convert to luma + float lt = dot(t, vec3(0.299, 0.587, 0.114)); + float lb = dot(b, vec3(0.299, 0.587, 0.114)); + float ll = dot(l, vec3(0.299, 0.587, 0.114)); + float lr = dot(r, vec3(0.299, 0.587, 0.114)); + float ltl = dot(tl, vec3(0.299, 0.587, 0.114)); + float ltr = dot(tr, vec3(0.299, 0.587, 0.114)); + float lbl = dot(bl, vec3(0.299, 0.587, 0.114)); + float lbr = dot(br, vec3(0.299, 0.587, 0.114)); + + // Sobel kernels + // Gx: -1 0 1 + // -2 0 2 + // -1 0 1 + float gx = (ltr + 2.0*lr + lbr) - (ltl + 2.0*ll + lbl); + + // Gy: -1 -2 -1 + // 0 0 0 + // 1 2 1 + float gy = (lbl + 2.0*lb + lbr) - (ltl + 2.0*lt + ltr); + + float mag = sqrt(gx*gx + gy*gy); + return vec2(mag, atan(gy, gx)); + } + void main() { vec2 uv = v_texCoord; @@ -148,27 +215,65 @@ export class WebGLAsciiRenderer { vec2 uvInCell = fract(uv * u_gridSize); // Sample image at the center of the cell - vec2 sampleUV = (cellCoords + 0.5) / u_gridSize; + vec2 cellSize = 1.0 / u_gridSize; + vec2 sampleUV = (cellCoords + 0.5) * cellSize; // Out of bounds check for zoomed UV if (sampleUV.x < 0.0 || sampleUV.x > 1.0 || sampleUV.y < 0.0 || sampleUV.y > 1.0) { discard; } - vec3 color = texture2D(u_image, sampleUV).rgb; + vec3 color; - // Edge Enhancement (Simple Laplacian-like check) - if (u_enhanceEdges) { - vec2 texel = 1.0 / u_gridSize; - vec3 center = texture2D(u_image, sampleUV).rgb; - vec3 top = texture2D(u_image, sampleUV + vec2(0.0, -texel.y)).rgb; - vec3 bottom = texture2D(u_image, sampleUV + vec2(0.0, texel.y)).rgb; - vec3 left = texture2D(u_image, sampleUV + vec2(-texel.x, 0.0)).rgb; - vec3 right = texture2D(u_image, sampleUV + vec2(texel.x, 0.0)).rgb; + // Denoise: 3x3 box blur (applied to the base sampling if enabled) + if (u_denoise) { + color = getAverageColor(sampleUV, cellSize * 2.0); + } else { + color = getAverageColor(sampleUV, cellSize); + } + + // Edge Detection Logic + if (u_edgeMode == 1) { + // Simple Laplacian-like + vec2 texel = cellSize; + vec3 center = color; + vec3 top = getAverageColor(sampleUV + vec2(0.0, -texel.y), cellSize); + vec3 bottom = getAverageColor(sampleUV + vec2(0.0, texel.y), cellSize); + vec3 left = getAverageColor(sampleUV + vec2(-texel.x, 0.0), cellSize); + vec3 right = getAverageColor(sampleUV + vec2(texel.x, 0.0), cellSize); vec3 edges = abs(center - top) + abs(center - bottom) + abs(center - left) + abs(center - right); float edgeLum = dot(edges, vec3(0.2126, 0.7152, 0.0722)); color = mix(color, color * (1.0 - edgeLum * 2.0), 0.5); + + } else if (u_edgeMode == 2) { + // Sobel Gradient + vec2 sobel = sobelFilter(sampleUV, cellSize); + float edgeStr = clamp(sobel.x * 2.0, 0.0, 1.0); + // Darken edges + color = mix(color, vec3(0.0), edgeStr * 0.8); + + } else if (u_edgeMode == 3) { + // "Canny-like" (Sobel + gradient suppression) + vec2 sobel = sobelFilter(sampleUV, cellSize); + float mag = sobel.x; + float angle = sobel.y; + + // Non-maximum suppression (simplified) + // Check neighbors in gradient direction + vec2 dir = vec2(cos(angle), sin(angle)) * cellSize; + + vec2 s1 = sobelFilter(sampleUV + dir, cellSize); + vec2 s2 = sobelFilter(sampleUV - dir, cellSize); + + if (mag < s1.x || mag < s2.x || mag < 0.15) { + mag = 0.0; + } else { + mag = 1.0; // Strong edge + } + + // Apply strong crisp edges + color = mix(color, vec3(0.0), mag); } // Apply adjustments @@ -189,6 +294,16 @@ export class WebGLAsciiRenderer { // Calculate luminance float luma = dot(color, vec3(0.2126, 0.7152, 0.0722)); + // Apply Blue Noise dithering before character mapping + if (u_dither > 0.0) { + // Use cell coordinates for stable dithering patterns + float noise = blueNoise(cellCoords); + + // Scale noise by dither strength and 1/charCount + luma = luma + noise * (1.0 / u_charCount) * u_dither; + luma = clamp(luma, 0.0, 1.0); + } + if (u_invert) { luma = 1.0 - luma; } @@ -197,9 +312,11 @@ export class WebGLAsciiRenderer { float charIndex = floor(luma * (u_charCount - 1.0) + 0.5); // Sample character atlas + // Use u_charSizeUV to scale, instead of just 1.0/u_charCount + // x = charIndex * charWidthUV + uvInCell.x * charWidthUV vec2 atlasUV = vec2( - (charIndex + uvInCell.x) / u_charCount, - uvInCell.y + (charIndex + uvInCell.x) * u_charSizeUV.x, + uvInCell.y * u_charSizeUV.y ); float charAlpha = texture2D(u_atlas, atlasUV).r; @@ -245,9 +362,10 @@ export class WebGLAsciiRenderer { if (!this.program) return; const gl = this.gl; const uniforms = [ - 'u_image', 'u_atlas', 'u_charCount', 'u_gridSize', 'u_texSize', + 'u_image', 'u_atlas', 'u_blueNoise', 'u_charCount', 'u_charSizeUV', 'u_gridSize', 'u_texSize', 'u_exposure', 'u_contrast', 'u_saturation', 'u_gamma', - 'u_invert', 'u_color', 'u_overlayStrength', 'u_enhanceEdges', + 'u_invert', 'u_color', 'u_overlayStrength', 'u_edgeMode', + 'u_dither', 'u_denoise', 'u_zoom', 'u_zoomCenter', 'u_mousePos', 'u_magnifierRadius', 'u_magnifierZoom', 'u_showMagnifier', 'u_aspect' ]; @@ -300,16 +418,32 @@ export class WebGLAsciiRenderer { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) return; + const fontSize = 32; // Higher resolution for atlas + // Add padding to prevent bleeding + const padding = 4; + ctx.font = `${fontSize}px ${fontName}`; // Measure first char to get dimensions const metrics = ctx.measureText('W'); - const charWidth = Math.ceil(metrics.width); - const charHeight = fontSize * 1.2; + const charContentWidth = Math.ceil(metrics.width); + const charContentHeight = Math.ceil(fontSize * 1.2); - canvas.width = charWidth * charSet.length; - canvas.height = charHeight; + // Full cell size including padding + const charWidth = charContentWidth + padding * 2; + const charHeight = charContentHeight + padding * 2; + + const neededWidth = charWidth * charSet.length; + const neededHeight = charHeight; + + // Calculate Next Power of Two + const nextPowerOfTwo = (v: number) => Math.pow(2, Math.ceil(Math.log(v) / Math.log(2))); + const texWidth = nextPowerOfTwo(neededWidth); + const texHeight = nextPowerOfTwo(neededHeight); + + canvas.width = texWidth; + canvas.height = texHeight; ctx.font = `${fontSize}px ${fontName}`; ctx.fillStyle = 'white'; @@ -317,7 +451,10 @@ export class WebGLAsciiRenderer { ctx.clearRect(0, 0, canvas.width, canvas.height); for (let i = 0; i < charSet.length; i++) { - ctx.fillText(charSet[i], i * charWidth, 0); + // Draw character centered in its padded cell + // x position: start of cell (i * charWidth) + padding + // y position: padding + ctx.fillText(charSet[i], i * charWidth + padding, padding); } const gl = this.gl; @@ -329,14 +466,17 @@ export class WebGLAsciiRenderer { gl.bindTexture(gl.TEXTURE_2D, this.textures.atlas); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + + // Use Mipmaps for smoother downscaling (fixes shimmering/aliasing) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.generateMipmap(gl.TEXTURE_2D); this.charAtlas = { - width: canvas.width, - height: canvas.height, + width: texWidth, + height: texHeight, charWidth, charHeight, count: charSet.length @@ -360,6 +500,11 @@ export class WebGLAsciiRenderer { this.updateAtlas(options.charSetContent, options.fontFamily || 'monospace'); if (this.charAtlas) { gl.uniform1f(u['u_charCount'], this.charAtlas.count); + // Pass the normalized size of one character cell for UV mapping + gl.uniform2f(u['u_charSizeUV'], + this.charAtlas.charWidth / this.charAtlas.width, + this.charAtlas.charHeight / this.charAtlas.height + ); gl.uniform2f(u['u_texSize'], this.charAtlas.width, this.charAtlas.height); } @@ -370,7 +515,9 @@ export class WebGLAsciiRenderer { gl.uniform1i(u['u_invert'], options.invert ? 1 : 0); gl.uniform1i(u['u_color'], options.color ? 1 : 0); gl.uniform1f(u['u_overlayStrength'], options.overlayStrength || 0.0); - gl.uniform1i(u['u_enhanceEdges'], options.enhanceEdges ? 1 : 0); + gl.uniform1i(u['u_edgeMode'], options.edgeMode || 0); + gl.uniform1f(u['u_dither'], options.dither || 0.0); + gl.uniform1i(u['u_denoise'], options.denoise ? 1 : 0); // Zoom & Magnifier gl.uniform1f(u['u_zoom'], options.zoom || 1.0); @@ -382,6 +529,33 @@ export class WebGLAsciiRenderer { gl.uniform1f(u['u_aspect'], gl.canvas.width / gl.canvas.height); } + private loadBlueNoiseTexture() { + const gl = this.gl; + const texture = gl.createTexture(); + if (!texture) return; + + this.textures.blueNoise = texture; + + const image = new Image(); + image.src = '/assets/blue-noise.png'; + image.onload = () => { + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT); + this.requestRender(); + }; + } + + // Helper to trigger a redraw if we have a controller reference, otherwise just rely on next loop + private requestRender() { + // Since we don't have a direct reference to the controller here, + // and we are in a render loop managed by the controller, + // the texture will just appear on the next frame. + } + updateTexture(image: HTMLImageElement) { if (this.lastImage === image && this.textures.image) return; @@ -434,6 +608,12 @@ export class WebGLAsciiRenderer { gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, this.textures.atlas); + if (this.textures.blueNoise) { + gl.uniform1i(u['u_blueNoise'], 2); + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_2D, this.textures.blueNoise); + } + gl.drawArrays(gl.TRIANGLES, 0, 6); } @@ -444,6 +624,39 @@ export class WebGLAsciiRenderer { this.draw(); } + /** + * Dispose of all WebGL resources. + * Call this when the renderer is no longer needed. + */ + dispose(): void { + const gl = this.gl; + + if (this.textures.image) { + gl.deleteTexture(this.textures.image); + } + if (this.textures.atlas) { + gl.deleteTexture(this.textures.atlas); + } + if (this.textures.blueNoise) { + gl.deleteTexture(this.textures.blueNoise); + } + if (this.buffers.position) { + gl.deleteBuffer(this.buffers.position); + } + if (this.buffers.texCoord) { + gl.deleteBuffer(this.buffers.texCoord); + } + if (this.program) { + gl.deleteProgram(this.program); + } + + this.textures = {}; + this.buffers = {}; + this.program = null; + this.charAtlas = null; + this.lastImage = null; + } + // Kept for backward compatibility or specialized updates updateMagnifier(options: MagnifierOptions) { const gl = this.gl;