/** * ASCII Renderer Controller * Manages state, render loop, and grid calculations. */ import { CHAR_SETS, type CharSetKey, type AsciiOptions, type AsciiSettings } from './ascii-shared'; import { WebGLAsciiRenderer, type RenderOptions } from './webgl-ascii'; import { AsciiExporter } from './ascii-exporter'; // ============= Types ============= 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 parent = this.canvas.parentElement; if (!parent) return; const fontAspectRatio = 0.55; const marginRatio = 0.05; // Reduced margin for container fit const screenW = parent.clientWidth; 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 parent = this.canvas.parentElement; if (!parent) return; const fontAspectRatio = 0.55; const gridAspect = (this.cachedGrid.widthCols * fontAspectRatio) / this.cachedGrid.heightRows; const screenW = parent.clientWidth; const screenH = parent.clientHeight; const maxW = screenW * 0.98; const maxH = screenH * 0.98; 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'); } } // ============= Export ============= savePNG(): void { const wasShowing = this.zoomState.showMagnifier; // 1. Force hide magnifier for clean export if (wasShowing) { this.zoomState.showMagnifier = false; this.requestRender('uniforms'); // Force synchronous render this.renderFrame(); } const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); AsciiExporter.downloadPNG(this.canvas, `ascii-art-${timestamp}.png`); // 2. Restore state if (wasShowing) { this.zoomState.showMagnifier = true; this.requestRender('uniforms'); } } saveText(): void { if (!this.cachedGrid.imgEl) return; const text = AsciiExporter.generateText( this.cachedGrid.imgEl, this.settings, this.cachedGrid.widthCols, Math.floor(this.cachedGrid.heightRows) ); const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); AsciiExporter.downloadText(text, `ascii-art-${timestamp}.txt`); } saveHTML(): void { if (!this.cachedGrid.imgEl) return; const html = AsciiExporter.generateHTML( this.cachedGrid.imgEl, this.settings, this.cachedGrid.widthCols, Math.floor(this.cachedGrid.heightRows) ); const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); AsciiExporter.downloadText(html, `ascii-art-${timestamp}.html`); } // ============= 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; } }