refactor(core): modularize ASCII logic and add dither/edge detection
This commit is contained in:
463
src/scripts/ascii-controller.ts
Normal file
463
src/scripts/ascii-controller.ts
Normal file
@@ -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<AsciiSettings> = {};
|
||||
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<K extends keyof AsciiSettings>(key: K): AsciiSettings[K] {
|
||||
return this.settings[key];
|
||||
}
|
||||
|
||||
setSetting<K extends keyof AsciiSettings>(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<AsciiOptions>): 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<AsciiSettings>;
|
||||
|
||||
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<GridCache | undefined> {
|
||||
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<void> {
|
||||
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<HTMLImageElement> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user