refactor(core): modularize ASCII logic and add dither/edge detection
This commit is contained in:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/assets/blue-noise.png
Normal file
BIN
public/assets/blue-noise.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
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;
|
||||
}
|
||||
}
|
||||
284
src/scripts/ascii-shared.ts
Normal file
284
src/scripts/ascii-shared.ts
Normal file
@@ -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<CharSetKey, string> = {
|
||||
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<string, AspectMode> = {
|
||||
fit: 'fit',
|
||||
fill: 'fill',
|
||||
stretch: 'stretch'
|
||||
};
|
||||
|
||||
export const EDGE_MODES: Record<string, EdgeMode> = {
|
||||
none: 'none',
|
||||
simple: 'simple',
|
||||
sobel: 'sobel',
|
||||
canny: 'canny'
|
||||
};
|
||||
|
||||
// Short keys for UI
|
||||
export const CHARSET_SHORT_MAP: Record<string, CharSetKey> = {
|
||||
STD: 'standard',
|
||||
EXT: 'extended',
|
||||
BLK: 'blocks',
|
||||
MIN: 'minimal',
|
||||
DOT: 'dots',
|
||||
SHP: 'shapes'
|
||||
};
|
||||
|
||||
export const CHARSET_REVERSE_MAP: Record<CharSetKey, string> = Object.fromEntries(
|
||||
Object.entries(CHARSET_SHORT_MAP).map(([k, v]) => [v, k])
|
||||
) as Record<CharSetKey, string>;
|
||||
|
||||
// ============= Auto-Tune =============
|
||||
|
||||
export function autoTuneImage(img: HTMLImageElement, meta: ImageMetadata | null = null): Partial<AsciiOptions> {
|
||||
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;
|
||||
}
|
||||
138
src/scripts/image-queue.ts
Normal file
138
src/scripts/image-queue.ts
Normal file
@@ -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<AsciiOptions>;
|
||||
}
|
||||
|
||||
// ============= 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<QueuedImage> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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?.();
|
||||
}
|
||||
}
|
||||
430
src/scripts/ui-bindings.ts
Normal file
430
src/scripts/ui-bindings.ts
Normal file
@@ -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<void>;
|
||||
private isUpdatingUI = false;
|
||||
|
||||
|
||||
// Event Handlers implementation references
|
||||
private sliderHandlers: Map<string, (e: Event) => void> = new Map();
|
||||
private wheelHandlers: Map<string, (e: WheelEvent) => void> = new Map();
|
||||
private toggleHandler: ((e: Event) => void) | null = null;
|
||||
private segmentHandlers: Map<string, (e: Event) => void> = new Map();
|
||||
private buttonHandlers: Map<string, (e: Event) => 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<void>
|
||||
) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user