506 lines
16 KiB
TypeScript
506 lines
16 KiB
TypeScript
/**
|
|
* 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<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 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<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');
|
|
}
|
|
}
|
|
|
|
// ============= 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<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;
|
|
}
|
|
}
|