Compare commits

...

3 Commits

Author SHA1 Message Date
syntaxbullet
a137a98377 refactor(page): migrate to new modular architecture 2026-02-09 22:34:14 +01:00
syntaxbullet
9b9976c70a refactor(core): modularize ASCII logic and add dither/edge detection 2026-02-09 22:33:58 +01:00
syntaxbullet
961383b402 feat(ui): add Tooltip component and update TUI controls 2026-02-09 22:33:40 +01:00
15 changed files with 1850 additions and 1287 deletions

10
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@astrojs/node": "^9.5.2", "@astrojs/node": "^9.5.2",
"astro": "^5.17.1", "astro": "^5.17.1",
"gifuct-js": "^2.1.2", "gifuct-js": "^2.1.2",
"pngjs": "^7.0.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
}, },
@@ -4387,6 +4388,15 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",

View File

@@ -19,6 +19,7 @@
"@astrojs/node": "^9.5.2", "@astrojs/node": "^9.5.2",
"astro": "^5.17.1", "astro": "^5.17.1",
"gifuct-js": "^2.1.2", "gifuct-js": "^2.1.2",
"pngjs": "^7.0.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,136 @@
---
---
<div id="tui-tooltip" class="tui-tooltip">
<div class="tooltip-header">
<span class="tooltip-title"></span>
</div>
<div class="tooltip-body">
<span class="tooltip-desc"></span>
</div>
</div>
<style>
.tui-tooltip {
position: fixed;
display: none;
pointer-events: none;
z-index: 1000;
background: rgba(10, 10, 10, 0.95);
border: 1px solid var(--text-color, #ff6700);
padding: 8px 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
min-width: 200px;
max-width: 300px;
backdrop-filter: blur(4px);
font-family: var(--font-mono, monospace);
will-change: transform, display;
}
.tooltip-header {
margin-bottom: 4px;
border-bottom: 1px solid rgba(255, 103, 0, 0.3);
padding-bottom: 4px;
}
.tooltip-title {
font-size: 11px;
font-weight: bold;
text-transform: uppercase;
color: var(--text-color, #ff6700);
letter-spacing: 1px;
}
.tooltip-desc {
font-size: 10px;
color: rgba(255, 255, 255, 0.8);
line-height: 1.4;
}
</style>
<script>
const tooltip = document.getElementById("tui-tooltip");
const titleEl = tooltip?.querySelector(".tooltip-title");
const descEl = tooltip?.querySelector(".tooltip-desc");
const OFFSET_X = 15;
const OFFSET_Y = 15;
if (tooltip && titleEl && descEl) {
let isVisible = false;
const updatePosition = (e: MouseEvent) => {
if (!isVisible) return;
const rect = tooltip.getBoundingClientRect();
const winW = window.innerWidth;
const winH = window.innerHeight;
// Calculate potential position
let x = e.clientX + OFFSET_X;
let y = e.clientY + OFFSET_Y;
// Flip horizontally if toolip goes off right edge
if (x + rect.width > winW) {
x = e.clientX - rect.width - OFFSET_X;
}
// Flip vertically if tooltip goes off bottom edge
if (y + rect.height > winH) {
y = e.clientY - rect.height - OFFSET_Y;
}
// Ensure it doesn't go off top/left
x = Math.max(0, x);
y = Math.max(0, y);
tooltip.style.left = `${x}px`;
tooltip.style.top = `${y}px`;
};
const showTooltip = (target: Element, e: MouseEvent) => {
const title = target.getAttribute("data-tooltip-title");
const desc = target.getAttribute("data-tooltip-desc");
if (title) {
titleEl.textContent = title;
descEl.textContent = desc || "";
tooltip.style.display = "block";
isVisible = true;
updatePosition(e);
}
};
const hideTooltip = () => {
tooltip.style.display = "none";
isVisible = false;
};
// Event delegation
document.addEventListener("mouseover", (e) => {
const target = (e.target as HTMLElement).closest(
"[data-tooltip-title]",
);
if (target) {
showTooltip(target, e as MouseEvent);
}
});
document.addEventListener("mouseout", (e) => {
const target = (e.target as HTMLElement).closest(
"[data-tooltip-title]",
);
if (target) {
const related = e.relatedTarget as HTMLElement;
if (related && target.contains(related)) return;
hideTooltip();
}
});
document.addEventListener("mousemove", (e) => {
if (isVisible) {
updatePosition(e as MouseEvent);
}
});
}
</script>

View File

@@ -5,16 +5,25 @@ interface Props {
shortcut?: string; shortcut?: string;
variant?: "default" | "primary" | "subtle"; variant?: "default" | "primary" | "subtle";
title?: string; title?: string;
description?: string;
} }
const { id, label, shortcut, variant = "default", title = "" } = Astro.props; const {
id,
label,
shortcut,
variant = "default",
title = "",
description = "",
} = Astro.props;
--- ---
<button <button
type="button" type="button"
class:list={["tui-button", `tui-button--${variant}`]} class:list={["tui-button", `tui-button--${variant}`]}
id={id} id={id}
title={title} data-tooltip-title={title}
data-tooltip-desc={description}
> >
{shortcut && <span class="tui-button-shortcut">{shortcut}</span>} {shortcut && <span class="tui-button-shortcut">{shortcut}</span>}
<span class="tui-button-label">{label}</span> <span class="tui-button-label">{label}</span>

View File

@@ -5,12 +5,25 @@ interface Props {
options: string[]; options: string[];
value?: string; value?: string;
title?: string; title?: string;
description?: string;
} }
const { id, label, options, value = options[0], title = "" } = Astro.props; const {
id,
label,
options,
value = options[0],
title = "",
description = "",
} = Astro.props;
--- ---
<div class="tui-segment" data-segment-id={id} title={title}> <div
class="tui-segment"
data-segment-id={id}
data-tooltip-title={title}
data-tooltip-desc={description}
>
<span class="tui-segment-label">{label}</span> <span class="tui-segment-label">{label}</span>
<div class="tui-segment-options" id={id} data-value={value}> <div class="tui-segment-options" id={id} data-value={value}>
{ {

View File

@@ -7,6 +7,7 @@ interface Props {
step?: number; step?: number;
value?: number; value?: number;
title?: string; title?: string;
description?: string;
} }
const { const {
@@ -17,13 +18,19 @@ const {
step = 0.1, step = 0.1,
value = 1.0, value = 1.0,
title = "", title = "",
description = "",
} = Astro.props; } = Astro.props;
// Generate slider visual (12 segments for better resolution) // Generate slider visual (12 segments for better resolution)
const segments = 12; const segments = 12;
--- ---
<div class="tui-slider" data-slider-id={id} title={title}> <div
class="tui-slider"
data-slider-id={id}
data-tooltip-title={title}
data-tooltip-desc={description}
>
<span class="tui-slider-label">{label}</span> <span class="tui-slider-label">{label}</span>
<div class="tui-slider-track-wrapper"> <div class="tui-slider-track-wrapper">
<div class="tui-slider-visual"> <div class="tui-slider-visual">
@@ -49,7 +56,7 @@ const segments = 12;
value={value} value={value}
/> />
</div> </div>
<span class="tui-slider-value" id={`val-${id}`}>{value.toFixed(1)}</span> <span class="tui-slider-value" id={`val-${id}`}>{value.toFixed(2)}</span>
</div> </div>
<style> <style>
@@ -179,7 +186,7 @@ const segments = 12;
} }
}); });
valueDisplay.textContent = val.toFixed(1); valueDisplay.textContent = val.toFixed(2);
} }
input.addEventListener("input", updateVisual); input.addEventListener("input", updateVisual);

View File

@@ -4,9 +4,16 @@ interface Props {
label: string; label: string;
checked?: boolean; checked?: boolean;
title?: string; title?: string;
description?: string;
} }
const { id, label, checked = false, title = "" } = Astro.props; const {
id,
label,
checked = false,
title = "",
description = "",
} = Astro.props;
--- ---
<button <button
@@ -14,7 +21,8 @@ const { id, label, checked = false, title = "" } = Astro.props;
class:list={["tui-toggle", { active: checked }]} class:list={["tui-toggle", { active: checked }]}
id={id} id={id}
data-checked={checked ? "true" : "false"} data-checked={checked ? "true" : "false"}
title={title} data-tooltip-title={title}
data-tooltip-desc={description}
> >
<span class="tui-toggle-label">{label}</span> <span class="tui-toggle-label">{label}</span>
</button> </button>

View File

@@ -4,13 +4,14 @@ import TuiSlider from "../components/TuiSlider.astro";
import TuiSegment from "../components/TuiSegment.astro"; import TuiSegment from "../components/TuiSegment.astro";
import TuiToggle from "../components/TuiToggle.astro"; import TuiToggle from "../components/TuiToggle.astro";
import TuiButton from "../components/TuiButton.astro"; import TuiButton from "../components/TuiButton.astro";
import Tooltip from "../components/Tooltip.astro";
--- ---
<Layout title="Neko ASCII Auto-Generator"> <Layout title="Syntaxbullet - Digital Wizard">
<div class="hero-wrapper"> <div class="hero-wrapper">
<!-- Background Layer: ASCII Art --> <!-- Background Layer: ASCII Art -->
<div class="ascii-layer"> <div class="ascii-layer">
<div id="loading">GENERATING...</div> <div id="loading">Loading...</div>
<pre id="ascii-result">Preparing art...</pre> <pre id="ascii-result">Preparing art...</pre>
<canvas id="ascii-canvas"></canvas> <canvas id="ascii-canvas"></canvas>
</div> </div>
@@ -20,9 +21,10 @@ import TuiButton from "../components/TuiButton.astro";
<div class="max-w-container"> <div class="max-w-container">
<main class="hero-content"> <main class="hero-content">
<div class="hero-text"> <div class="hero-text">
<h2>AUTOMATED<br />ASCII<br />SYNTHESIS</h2> <h2>SYNTAXBULLET</h2>
<p class="tagline"> <p class="tagline">
Real-time image-to-text conversion engine. Self-taught Munich-based software engineer
passionate about Generative AI, Linux, and the Web.
</p> </p>
</div> </div>
</main> </main>
@@ -38,54 +40,70 @@ import TuiButton from "../components/TuiButton.astro";
label="EXP" label="EXP"
min={0} min={0}
max={3} max={3}
step={0.1} step={0.01}
value={1.0} value={1.0}
title="Exposure / Brightness" title="Exposure / Brightness"
description="Adjusts the overall brightness level of the input image before processing."
/> />
<TuiSlider <TuiSlider
id="contrast" id="contrast"
label="CON" label="CON"
min={0} min={0}
max={3} max={3}
step={0.1} step={0.01}
value={1.0} value={1.0}
title="Contrast" title="Contrast"
description="Increases or decreases the difference between light and dark areas."
/> />
<TuiSlider <TuiSlider
id="saturation" id="saturation"
label="SAT" label="SAT"
min={0} min={0}
max={3} max={3}
step={0.1} step={0.01}
value={1.2} value={1.2}
title="Saturation" title="Saturation"
description="Controls color intensity. Higher values make colors more vibrant in Color Mode."
/> />
<TuiSlider <TuiSlider
id="gamma" id="gamma"
label="GAM" label="GAM"
min={0} min={0}
max={3} max={3}
step={0.1} step={0.01}
value={1.0} value={1.0}
title="Gamma Correction" title="Gamma Correction"
description="Non-linear brightness adjustment. useful for correcting washed-out or too dark images."
/> />
<TuiSlider <TuiSlider
id="overlayStrength" id="overlayStrength"
label="OVL" label="OVL"
min={0} min={0}
max={1} max={1}
step={0.1} step={0.01}
value={0.3} value={0.3}
title="Overlay Blend Strength" title="Overlay Blend Strength"
description="Blends the original image over the ASCII output. 0 is pure ASCII, 1 is original image."
/> />
<TuiSlider <TuiSlider
id="resolution" id="resolution"
label="RES" label="RES"
min={0.5} min={0.1}
max={2} max={2}
step={0.1} step={0.01}
value={1.0} value={1.0}
title="Resolution Scale" title="Resolution Scale"
description="Adjusts the density of characters. Higher values give more detail but may reduce performance."
/>
<TuiSlider
id="dither"
label="DTH"
min={0}
max={1}
step={0.01}
value={0}
title="Dither Strength"
description="Applies ordered dithering to simulate shading. Useful for low-contrast areas."
/> />
</div> </div>
</div> </div>
@@ -101,21 +119,14 @@ import TuiButton from "../components/TuiButton.astro";
id="toggle-color" id="toggle-color"
label="CLR" label="CLR"
title="Color Output (HTML)" title="Color Output (HTML)"
description="Toggles between monochrome text and colored HTML spans."
/> />
<TuiToggle
id="toggle-dither"
label="DTH"
title="Floyd-Steinberg Dithering"
/>
<TuiToggle <TuiToggle
id="toggle-denoise" id="toggle-denoise"
label="DNZ" label="DNZ"
title="Denoise Pre-processing" title="Denoise Pre-processing"
/> description="Applies a bilateral filter to reduce image noise while preserving edges."
<TuiToggle
id="toggle-edges"
label="EDG"
title="Edge Enhancement"
/> />
</div> </div>
@@ -132,6 +143,15 @@ import TuiButton from "../components/TuiButton.astro";
options={["AUTO", "ON", "OFF"]} options={["AUTO", "ON", "OFF"]}
value="AUTO" value="AUTO"
title="Invert Colors" title="Invert Colors"
description="Inverts brightness mapping. AUTO detects dark/light mode."
/>
<TuiSegment
id="segment-edge"
label="EDG"
options={["OFF", "SPL", "SOB", "CNY"]}
value="OFF"
title="Edge Detection Mode"
description="Algorithm used to detect edges. SPL: Simple, SOB: Sobel, CNY: Canny."
/> />
<TuiSegment <TuiSegment
id="segment-charset" id="segment-charset"
@@ -146,6 +166,7 @@ import TuiButton from "../components/TuiButton.astro";
]} ]}
value="STD" value="STD"
title="Character Set" title="Character Set"
description="The set of characters used for mapping brightness levels."
/> />
</div> </div>
</div> </div>
@@ -162,17 +183,21 @@ import TuiButton from "../components/TuiButton.astro";
label="RESET" label="RESET"
shortcut="R" shortcut="R"
title="Reset to Auto-detected Settings" title="Reset to Auto-detected Settings"
description="Resets all sliders and toggles to their default values."
/> />
<TuiButton <TuiButton
id="btn-next" id="btn-next"
label="NEXT" label="NEXT"
shortcut="N" shortcut="N"
variant="primary" variant="primary"
title="Load Next Image" title="Load Next Image"
description="Discards current image and loads a new one from the queue."
/> />
<div <div
class="queue-display" class="queue-display"
title="Buffered Images" data-tooltip-title="Buffered Images"
data-tooltip-desc="Number of images pre-loaded in background queue."
> >
<span class="queue-label">Q:</span> <span class="queue-label">Q:</span>
<span id="val-queue" class="queue-value" <span id="val-queue" class="queue-value"
@@ -199,76 +224,25 @@ import TuiButton from "../components/TuiButton.astro";
</div> </div>
<script> <script>
import { import { AsciiController } from "../scripts/ascii-controller";
AsciiGenerator, import { ImageQueue } from "../scripts/image-queue";
autoTuneImage, import { UIBindings } from "../scripts/ui-bindings";
CHAR_SETS,
} from "../scripts/ascii.js";
import {
fetchRandomAnimeImage,
loadSingleImage,
} from "../scripts/anime-api.js";
import { WebGLAsciiRenderer } from "../scripts/webgl-ascii.js";
const generator = new AsciiGenerator(); // ============= Global Cleanup Protocol =============
// Fix for accumulating event listeners and render loops during HMR/Navigation
if (window.__ASCII_APP__) {
console.log("♻️ Disposing previous application instance...");
try {
window.__ASCII_APP__.dispose();
} catch (e) {
console.error("Failed to dispose previous instance:", e);
}
}
// ============= DOM Elements =============
const canvas = document.getElementById( const canvas = document.getElementById(
"ascii-canvas", "ascii-canvas",
) as HTMLCanvasElement; ) as HTMLCanvasElement;
let webglRenderer: WebGLAsciiRenderer | null = null;
let isWebGLAvailable = false;
try {
webglRenderer = new WebGLAsciiRenderer(canvas);
isWebGLAvailable = true;
} catch (e) {
console.warn(
"WebGL renderer failed to initialize, falling back to CPU",
e,
);
isWebGLAvailable = false;
}
// State
let currentImgUrl: string | null = null;
let currentSettings: Record<string, any> = {
exposure: 1.0,
contrast: 1.0,
saturation: 1.2,
gamma: 1.0,
invert: false,
color: false,
dither: false,
denoise: false,
enhanceEdges: false,
overlayStrength: 0.3,
resolution: 1.0,
charSet: "standard",
};
let invertMode = "auto"; // 'auto', 'on', 'off'
let detectedInvert = false;
let detectedSettings: any = {}; // Store auto-detected settings
// Render Loop State
let dirtyTexture = false;
let dirtyGrid = false;
let dirtyUniforms = false;
// Cache for grid calculations
let cachedGrid: {
widthCols: number;
heightRows: number;
imgEl: HTMLImageElement | null;
} = {
widthCols: 0,
heightRows: 0,
imgEl: null,
};
// Debounce for CPU render
let cpuRenderTimeout: number | undefined;
// DOM Elements
const asciiResult = document.getElementById( const asciiResult = document.getElementById(
"ascii-result", "ascii-result",
) as HTMLPreElement; ) as HTMLPreElement;
@@ -276,464 +250,57 @@ import TuiButton from "../components/TuiButton.astro";
"loading", "loading",
) as HTMLDivElement; ) as HTMLDivElement;
if (!asciiResult || !loadingIndicator || !canvas) { if (!canvas || !asciiResult || !loadingIndicator) {
throw new Error("Critical UI elements missing"); throw new Error("Critical UI elements missing");
} }
// Charset key mapping (short to full) // ============= Initialize =============
const charSetKeyMap: Record<string, string> = { const controller = new AsciiController(
STD: "standard", canvas,
EXT: "extended", asciiResult,
BLK: "blocks", loadingIndicator,
MIN: "minimal",
DOT: "dots",
SHP: "shapes",
};
const charSetReverseMap: Record<string, string> = Object.fromEntries(
Object.entries(charSetKeyMap).map(([k, v]) => [v, k]),
); );
const queue = new ImageQueue(2);
const ui = new UIBindings(controller, queue, loadNewImage);
// Update UI to reflect current settings using new components // Store instances globally for cleanup
function updateUI() { window.__ASCII_APP__ = {
// Update sliders controller,
const sliderIds = [ queue,
"exposure", ui,
"contrast", dispose: () => {
"saturation", controller.dispose();
"gamma", ui.dispose();
"overlayStrength", queue.dispose();
"resolution", window.__ASCII_APP__ = undefined;
];
sliderIds.forEach((id) => {
const input = document.getElementById(id) as HTMLInputElement;
// removed unused valueDisplay
if (input && currentSettings[id] !== undefined) {
input.value = String(currentSettings[id]);
input.dispatchEvent(new Event("input"));
}
});
// Update toggles
(window as any).updateToggleState?.(
"toggle-color",
currentSettings.color,
);
(window as any).updateToggleState?.(
"toggle-dither",
currentSettings.dither,
);
(window as any).updateToggleState?.(
"toggle-denoise",
currentSettings.denoise,
);
(window as any).updateToggleState?.(
"toggle-edges",
currentSettings.enhanceEdges,
);
// Update segments
const invertValue =
invertMode === "auto"
? "AUTO"
: currentSettings.invert
? "ON"
: "OFF";
(window as any).updateSegmentValue?.("segment-invert", invertValue);
const charSetShort =
charSetReverseMap[currentSettings.charSet] || "STD";
(window as any).updateSegmentValue?.(
"segment-charset",
charSetShort,
);
// Update queue status
updateQueueStatus();
}
function updateQueueStatus() {
const queueEl = document.getElementById("val-queue");
if (queueEl) {
queueEl.textContent = imageQueue.length.toString();
}
}
function resetToAutoSettings() {
if (Object.keys(detectedSettings).length > 0) {
invertMode = "auto";
detectedInvert = detectedSettings.invert ?? false;
currentSettings = {
...currentSettings,
...detectedSettings,
resolution: currentSettings.resolution, // Keep resolution
color: false, // Reset color to off
};
currentSettings.invert = detectedInvert;
updateUI();
// Full update
calculateGrid().then(() => {
requestRender("all");
});
}
}
function requestRender(type: "texture" | "grid" | "uniforms" | "all") {
if (!isWebGLAvailable) {
// For CPU, we just debounce a full render
clearTimeout(cpuRenderTimeout);
cpuRenderTimeout = window.setTimeout(() => generateCPU(), 50);
return;
}
if (type === "all") {
dirtyTexture = true;
dirtyGrid = true;
dirtyUniforms = true;
} else if (type === "texture") dirtyTexture = true;
else if (type === "grid") dirtyGrid = true;
else if (type === "uniforms") dirtyUniforms = true;
}
async function calculateGrid() {
if (!currentImgUrl) return;
// Dynamic sizing logic to fit screen
const fontAspectRatio = 0.55;
const marginRatio = 0.2;
const screenW = window.innerWidth;
// Available space
const availW = screenW * (1 - marginRatio);
let widthCols = Math.floor(availW / 6); // Assuming ~6px char width
// Apply resolution scaling
widthCols = Math.floor(widthCols * currentSettings.resolution);
if (widthCols > 300) widthCols = 300; // Cap to prevent crashing
if (widthCols < 40) widthCols = 40;
const imgEl = await resolveImage(currentImgUrl);
const imgRatio = imgEl.width / imgEl.height;
const heightRows = widthCols / (imgRatio / fontAspectRatio);
cachedGrid = {
widthCols,
heightRows,
imgEl,
};
return cachedGrid;
}
function renderLoop() {
if (isWebGLAvailable && webglRenderer && cachedGrid.imgEl) {
const charSetContent =
CHAR_SETS[
currentSettings.charSet as keyof typeof CHAR_SETS
] || CHAR_SETS.standard;
// Only act if dirty
if (dirtyTexture || dirtyGrid || dirtyUniforms) {
if (dirtyTexture) {
webglRenderer.updateTexture(cachedGrid.imgEl);
}
if (dirtyGrid) {
// Recalculate canvas size for WebGL
const fontAspectRatio = 0.55;
const gridAspect =
(cachedGrid.widthCols * fontAspectRatio) /
cachedGrid.heightRows;
const screenW = window.innerWidth;
const screenH = window.innerHeight;
const maxW = screenW * 0.95;
const maxH = screenH * 0.95;
let finalW, finalH;
if (gridAspect > maxW / maxH) {
finalW = maxW;
finalH = maxW / gridAspect;
} else {
finalH = maxH;
finalW = maxH * gridAspect;
}
canvas.style.width = `${finalW}px`;
canvas.style.height = `${finalH}px`;
const dpr = window.devicePixelRatio || 1;
canvas.width = finalW * dpr;
canvas.height = finalH * dpr;
webglRenderer.updateGrid(
cachedGrid.widthCols,
Math.floor(cachedGrid.heightRows),
);
}
if (dirtyUniforms || dirtyGrid) {
// Uniforms often depend on grid/atlas state
webglRenderer.updateUniforms({
width: cachedGrid.widthCols,
height: Math.floor(cachedGrid.heightRows),
charSetContent: charSetContent,
...currentSettings,
dither: currentSettings.dither,
denoise: currentSettings.denoise,
// WebGLAsciiRenderer handles Defaults for zoom/magnifier if undefined
zoom: zoom,
zoomCenter: zoomCenter,
mousePos: mousePos,
showMagnifier: showMagnifier,
magnifierRadius: 0.15,
magnifierZoom: 2.5,
} as any);
}
webglRenderer.draw();
dirtyTexture = false;
dirtyGrid = false;
dirtyUniforms = false;
}
}
requestAnimationFrame(renderLoop);
}
// Start the loop
requestAnimationFrame(renderLoop);
async function generateCPU() {
if (!cachedGrid.imgEl) await calculateGrid();
if (!cachedGrid.imgEl) return;
canvas.style.display = "none";
asciiResult.style.display = "block";
try {
const result = await generator.generate(cachedGrid.imgEl, {
width: cachedGrid.widthCols,
height: Math.floor(cachedGrid.heightRows),
...currentSettings,
});
// Handle color output (returns object) vs plain text (returns string)
if (typeof result === "object" && result.isHtml) {
asciiResult.innerHTML = result.output;
} else {
asciiResult.textContent = result as string;
}
// Auto-fit font size
const fontAspectRatio = 0.55;
const screenW = window.innerWidth;
const screenH = window.innerHeight;
const sizeW =
(screenW * 0.9) / (cachedGrid.widthCols * fontAspectRatio);
const sizeH = (screenH * 0.9) / cachedGrid.heightRows;
const bestSize = Math.min(sizeW, sizeH);
asciiResult.style.fontSize = `${Math.max(4, bestSize).toFixed(2)}px`;
asciiResult.style.opacity = "1";
} catch (e) {
console.error("Render error", e);
}
}
async function generate() {
// Legacy wrapper to kick off a render (used by resize listener/init)
await calculateGrid();
if (isWebGLAvailable) {
asciiResult.style.display = "none";
canvas.style.display = "block";
canvas.style.opacity = "1";
requestRender("all");
} else {
generateCPU();
}
}
// Zoom & Magnifier State
let zoom = 1.0;
let zoomCenter = { x: 0.5, y: 0.5 };
let mousePos = { x: -1, y: -1 };
let showMagnifier = false;
const heroWrapper = document.querySelector(".hero-wrapper");
if (heroWrapper) {
heroWrapper.addEventListener(
"wheel",
(e: any) => {
// If over controls, don't zoom
if (e.target.closest("#tui-controls")) return;
// Only zoom if using WebGL (as CPU version doesn't support it yet)
if (webglRenderer) {
e.preventDefault();
const delta = -e.deltaY;
const factor = delta > 0 ? 1.1 : 0.9;
const oldZoom = zoom;
zoom *= factor;
// Cap zoom
zoom = Math.min(Math.max(zoom, 1.0), 10.0);
if (zoom === 1.0) {
zoomCenter = { x: 0.5, y: 0.5 };
} else if (oldZoom !== zoom) {
// Calculate where the mouse is relative to the canvas
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) / rect.width;
const my = (e.clientY - rect.top) / rect.height;
// To zoom into the mouse, we want the image coordinate under the mouse to stay fixed.
// Shader formula: uv = (v_texCoord - C) / Z + C
// We want: (mx - C1) / Z1 + C1 == (mx - C2) / Z2 + C2
const imgX =
(mx - zoomCenter.x) / oldZoom + zoomCenter.x;
const imgY =
(my - zoomCenter.y) / oldZoom + zoomCenter.y;
// Solve for C2: K = (mx - C2) / Z2 + C2 => C2 = (K - mx/Z2) / (1 - 1/Z2)
zoomCenter.x = (imgX - mx / zoom) / (1 - 1 / zoom);
zoomCenter.y = (imgY - my / zoom) / (1 - 1 / zoom);
}
requestRender("uniforms");
}
}, },
{ passive: false }, };
);
let magnifierTimeout: ReturnType<typeof setTimeout> | undefined; // Link settings updates to UI sync
controller.onSettingsChanged(() => ui.updateUI());
heroWrapper.addEventListener("mousemove", (e: any) => {
if (webglRenderer) {
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) / rect.width;
const my = (e.clientY - rect.top) / rect.height;
mousePos = { x: mx, y: my };
// Show magnifier if mouse is over canvas
const wasShowing = showMagnifier;
showMagnifier = mx >= 0 && mx <= 1 && my >= 0 && my <= 1;
if (showMagnifier || wasShowing) {
requestRender("uniforms");
}
}
});
heroWrapper.addEventListener("mouseleave", () => {
if (showMagnifier) {
showMagnifier = false;
requestRender("uniforms");
}
});
}
// Queue System
const imageQueue: { data: any; imgElement: HTMLImageElement }[] = [];
let isFetchingNext = false;
const MAX_QUEUE_SIZE = 2;
async function prefetchNext() {
if (isFetchingNext || imageQueue.length >= MAX_QUEUE_SIZE) return;
if (document.hidden) return;
isFetchingNext = true;
try {
const data = await fetchRandomAnimeImage();
loadingIndicator.style.display = "block";
asciiResult.textContent = `FETCHING... (${imageQueue.length + 1}/${MAX_QUEUE_SIZE})`;
asciiResult.style.opacity = "0.5";
const img = await loadSingleImage(data.url);
imageQueue.push({ data, imgElement: img });
updateQueueStatus();
loadingIndicator.style.display = "none";
} catch (e) {
console.error("Failed to prefetch image:", e);
loadingIndicator.style.display = "none";
} finally {
isFetchingNext = false;
}
}
async function ensureQueueFilled() {
while (imageQueue.length < MAX_QUEUE_SIZE) {
await prefetchNext();
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
let retryCount = 0; let retryCount = 0;
const MAX_RETRIES = 3; const MAX_RETRIES = 3;
async function loadNewImage() { // ============= Image Loading =============
async function loadNewImage(): Promise<void> {
try { try {
let suggestions: any; let item;
// If queue is empty, show loading and wait for fetch if (queue.getLength() === 0) {
if (imageQueue.length === 0) { controller.showLoading("FETCHING...");
loadingIndicator.style.display = "block"; item = await queue.fetchDirect();
asciiResult.textContent = "FETCHING...";
asciiResult.style.opacity = "0.5";
const data = await fetchRandomAnimeImage();
const img = await loadSingleImage(data.url);
currentImgUrl = data.url;
suggestions = autoTuneImage(img, data.meta);
loadingIndicator.style.display = "none";
} else { } else {
// Pop from queue item = queue.pop()!;
const nextItem = imageQueue.shift()!; queue.ensureFilled(); // Background refill
currentImgUrl = nextItem.data.url;
suggestions = autoTuneImage(
nextItem.imgElement as HTMLImageElement,
nextItem.data.meta,
);
// Trigger refill in background
ensureQueueFilled();
} }
// Reset zoom on new image controller.setCurrentImage(item.url, item.suggestions);
zoom = 1.0; retryCount = 0;
zoomCenter = { x: 0.5, y: 0.5 };
// Reset auto mode and apply auto-detected settings ui.updateUI();
invertMode = "auto"; await controller.generate();
detectedInvert = suggestions.invert; controller.hideLoading();
detectedSettings = suggestions;
currentSettings = {
...currentSettings,
...suggestions,
// Keep resolution as is
resolution: currentSettings.resolution,
// Keep manual toggles if they were set
color: currentSettings.color,
};
currentSettings.invert = detectedInvert;
retryCount = 0; // Reset retries on success
updateUI();
await generate();
loadingIndicator.style.display = "none";
asciiResult.style.opacity = "1";
} catch (e) { } catch (e) {
console.error(e); console.error(e);
if (retryCount < MAX_RETRIES) { if (retryCount < MAX_RETRIES) {
@@ -742,188 +309,18 @@ import TuiButton from "../components/TuiButton.astro";
setTimeout(loadNewImage, 2000); setTimeout(loadNewImage, 2000);
} else { } else {
asciiResult.textContent = "SIGNAL LOST. PLEASE REFRESH."; asciiResult.textContent = "SIGNAL LOST. PLEASE REFRESH.";
loadingIndicator.style.display = "none"; controller.hideLoading();
} }
} }
} }
function resolveImage(src: string): Promise<HTMLImageElement> { // ============= Initialize UI and Load First Image =============
return new Promise<HTMLImageElement>((resolve, reject) => { ui.init();
const img = new Image();
img.crossOrigin = "Anonymous";
img.src = src;
img.onload = () => resolve(img);
img.onerror = reject;
});
}
// ============= NEW COMPONENT EVENT LISTENERS =============
// Slider change events
const sliderIds = [
"exposure",
"contrast",
"saturation",
"gamma",
"overlayStrength",
"resolution",
];
sliderIds.forEach((id) => {
const input = document.getElementById(id) as HTMLInputElement;
if (input) {
input.addEventListener("input", () => {
currentSettings[id] = parseFloat(input.value);
if (id === "resolution") {
calculateGrid().then(() => requestRender("grid"));
} else {
requestRender("uniforms");
}
});
}
});
// Toggle change events - use event delegation to catch events from dynamically initialized toggles
document.body.addEventListener("toggle-change", (e: any) => {
const target = e.target as HTMLElement;
if (!target) return;
const toggleId = target.id;
const checked = e.detail?.checked;
switch (toggleId) {
case "toggle-color":
currentSettings.color = checked;
requestRender("uniforms");
break;
case "toggle-dither":
currentSettings.dither = checked;
requestRender("uniforms");
break;
case "toggle-denoise":
currentSettings.denoise = checked;
requestRender("uniforms");
break;
case "toggle-edges":
currentSettings.enhanceEdges = checked;
requestRender("uniforms");
break;
}
});
// Segment change events
document
.getElementById("segment-invert")
?.addEventListener("segment-change", (e: any) => {
const value = e.detail.value;
if (value === "AUTO") {
invertMode = "auto";
currentSettings.invert = detectedInvert;
} else if (value === "ON") {
invertMode = "on";
currentSettings.invert = true;
} else {
invertMode = "off";
currentSettings.invert = false;
}
requestRender("uniforms");
});
document
.getElementById("segment-charset")
?.addEventListener("segment-change", (e: any) => {
const shortKey = e.detail.value;
currentSettings.charSet = charSetKeyMap[shortKey] || "standard";
requestRender("uniforms"); // Charset update uses updateUniforms -> updateAtlas
});
// Action button events
document.getElementById("btn-reset")?.addEventListener("click", (e) => {
e.stopPropagation();
resetToAutoSettings();
});
document.getElementById("btn-next")?.addEventListener("click", (e) => {
e.stopPropagation();
loadNewImage();
});
// Keyboard shortcuts
document.addEventListener("keydown", (e: KeyboardEvent) => {
// Ignore if user is typing in an input
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
)
return;
switch (e.key.toLowerCase()) {
case "n": // Next image
loadNewImage();
break;
case "r": // Reset
resetToAutoSettings();
break;
case "i": // Cycle invert (AUTO -> ON -> OFF -> AUTO)
{
// removed unused invertSegment declaration
if (invertMode === "auto") {
invertMode = "on";
currentSettings.invert = true;
} else if (invertMode === "on") {
invertMode = "off";
currentSettings.invert = false;
} else {
invertMode = "auto";
currentSettings.invert = detectedInvert;
}
updateUI();
requestRender("uniforms");
}
break;
case "c": // Toggle color
currentSettings.color = !currentSettings.color;
updateUI();
requestRender("uniforms");
break;
case "d": // Toggle dither
currentSettings.dither = !currentSettings.dither;
updateUI();
requestRender("uniforms");
break;
case "e": // Toggle edges
currentSettings.enhanceEdges =
!currentSettings.enhanceEdges;
updateUI();
requestRender("uniforms");
break;
case "s": // Cycle charset
{
const keys = Object.keys(CHAR_SETS);
const idx = keys.indexOf(currentSettings.charSet);
const nextIdx = (idx + 1) % keys.length;
currentSettings.charSet = keys[nextIdx];
updateUI();
requestRender("uniforms");
}
break;
}
});
// Resize handler
let resizeTimeout: ReturnType<typeof setTimeout> | undefined;
window.addEventListener("resize", () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(generate, 200);
});
// Periodic queue status update
setInterval(updateQueueStatus, 1000);
// Init
loadNewImage().then(() => { loadNewImage().then(() => {
ensureQueueFilled(); // Start filling queue after first load queue.ensureFilled();
}); });
</script> </script>
<Tooltip />
</Layout> </Layout>
<style> <style>

View 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
View 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;
}

View File

@@ -1,546 +0,0 @@
export interface AsciiOptions {
width?: number;
height?: number;
contrast?: number;
exposure?: number;
invert?: boolean;
saturation?: number;
gamma?: number;
charSet?: CharSetKey | string;
color?: boolean;
dither?: boolean;
enhanceEdges?: boolean;
autoStretch?: boolean;
overlayStrength?: number;
aspectMode?: 'fit' | 'fill' | 'stretch';
denoise?: boolean;
fontAspectRatio?: number;
onProgress?: (progress: number) => void;
}
export interface AsciiResult {
output: string;
isHtml: boolean;
width: number;
height: number;
}
export type CharSetKey = 'standard' | 'simple' | 'blocks' | 'minimal' | 'matrix' | 'dots' | 'ascii_extended';
export type AspectMode = 'fit' | 'fill' | 'stretch';
export const CHAR_SETS: Record<CharSetKey, string> = {
standard: '@W%$NQ08GBR&ODHKUgSMw#Xbdp5q9C26APahk3EFVesm{}o4JZcjnuy[f1xi*7zYt(l/I\\v)T?]r><+^"L;|!~:,-_.\' ',
simple: '@%#*+=-:. ',
blocks: '█▓▒░ ',
minimal: '#+-. ',
matrix: 'ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ1234567890:.=*+-<>',
dots: '⣿⣷⣯⣟⡿⢿⣻⣽⣾⣶⣦⣤⣄⣀⡀ ',
ascii_extended: '░▒▓█▀▄▌▐│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌ '
};
export const ASPECT_MODES: Record<string, AspectMode> = {
fit: 'fit',
fill: 'fill',
stretch: 'stretch'
};
interface ImageMetadata {
color_dominant?: [number, number, number];
color_palette?: [number, number, number][];
has_fine_detail?: boolean;
}
export class AsciiGenerator {
private ctx: CanvasRenderingContext2D | null = null;
private canvas: HTMLCanvasElement | null = null;
private sharpCanvas: HTMLCanvasElement | null = null;
private sharpCtx: CanvasRenderingContext2D | null = null;
private denoiseCanvas: HTMLCanvasElement | null = null;
private denoiseCtx: CanvasRenderingContext2D | null = null;
private colorData: Uint8Array | null = null;
dispose(): void {
this.ctx = null;
this.sharpCtx = null;
this.denoiseCtx = null;
this.colorData = null;
if (this.canvas) {
this.canvas.width = 0;
this.canvas.height = 0;
this.canvas = null;
}
if (this.sharpCanvas) {
this.sharpCanvas.width = 0;
this.sharpCanvas.height = 0;
this.sharpCanvas = null;
}
if (this.denoiseCanvas) {
this.denoiseCanvas.width = 0;
this.denoiseCanvas.height = 0;
this.denoiseCanvas = null;
}
}
async generate(imageSource: string | HTMLImageElement, options: AsciiOptions = {}): Promise<string | AsciiResult> {
if (typeof document === 'undefined') {
throw new Error('AsciiGenerator requires a browser environment.');
}
const onProgress = options.onProgress ?? (() => { });
onProgress(0);
const img = await this.resolveImage(imageSource);
onProgress(10);
const requestedWidth = options.width ?? 100;
const fontAspectRatio = options.fontAspectRatio ?? 0.55;
const imgRatio = this.getImageRatio(img);
const aspectMode = options.aspectMode ?? 'fit';
let width: number, height: number;
if (aspectMode === 'stretch') {
width = requestedWidth;
height = options.height ?? Math.floor(requestedWidth / 2);
} else if (aspectMode === 'fill') {
width = requestedWidth;
const naturalHeight = Math.floor(requestedWidth / (imgRatio / fontAspectRatio));
height = options.height ?? naturalHeight;
} else {
width = requestedWidth;
height = options.height ?? Math.floor(requestedWidth / (imgRatio / fontAspectRatio));
}
let charSet: string = options.charSet ?? 'standard';
if (charSet in CHAR_SETS) {
charSet = CHAR_SETS[charSet as CharSetKey];
}
if (!this.canvas) {
this.canvas = document.createElement('canvas');
}
this.canvas.width = width;
this.canvas.height = height;
this.ctx = this.canvas.getContext('2d');
if (!this.sharpCanvas) {
this.sharpCanvas = document.createElement('canvas');
}
this.sharpCanvas.width = width;
this.sharpCanvas.height = height;
this.sharpCtx = this.sharpCanvas.getContext('2d');
const exposure = options.exposure ?? 1.0;
const contrast = options.contrast ?? 1.0;
const saturation = options.saturation ?? 1.2;
const gamma = options.gamma ?? 1.0;
const dither = options.dither ?? false;
const enhanceEdges = options.enhanceEdges ?? false;
const autoStretch = options.autoStretch !== false;
const overlayStrength = options.overlayStrength ?? 0.3;
const denoise = options.denoise ?? false;
const colorOutput = options.color ?? false;
onProgress(20);
let sourceImage: HTMLImageElement | HTMLCanvasElement = img;
if (denoise) {
if (!this.denoiseCanvas) {
this.denoiseCanvas = document.createElement('canvas');
}
this.denoiseCanvas.width = width;
this.denoiseCanvas.height = height;
this.denoiseCtx = this.denoiseCanvas.getContext('2d');
if (this.denoiseCtx) {
this.denoiseCtx.filter = 'blur(0.5px)';
this.denoiseCtx.drawImage(img, 0, 0, width, height);
sourceImage = this.denoiseCanvas;
}
}
let sx = 0, sy = 0, sw = img.width, sh = img.height;
if (aspectMode === 'fill' && options.height) {
const targetRatio = width / (options.height * fontAspectRatio);
if (imgRatio > targetRatio) {
sw = img.height * targetRatio;
sx = (img.width - sw) / 2;
} else {
sh = img.width / targetRatio;
sy = (img.height - sh) / 2;
}
}
if (this.sharpCtx) {
this.sharpCtx.filter = `brightness(${exposure}) contrast(${contrast}) saturate(${saturation})`;
if (denoise && sourceImage === this.denoiseCanvas) {
this.sharpCtx.drawImage(sourceImage, 0, 0, width, height);
} else {
this.sharpCtx.drawImage(img, sx, sy, sw, sh, 0, 0, width, height);
}
}
if (enhanceEdges && this.sharpCtx) {
this.sharpCtx.filter = 'none';
this.sharpCtx.globalCompositeOperation = 'source-over';
const edgeCanvas = document.createElement('canvas');
edgeCanvas.width = width;
edgeCanvas.height = height;
const edgeCtx = edgeCanvas.getContext('2d');
if (edgeCtx) {
edgeCtx.filter = 'contrast(2) brightness(0.8)';
edgeCtx.drawImage(this.sharpCanvas!, 0, 0);
this.sharpCtx.globalAlpha = 0.4;
this.sharpCtx.globalCompositeOperation = 'multiply';
this.sharpCtx.drawImage(edgeCanvas, 0, 0);
this.sharpCtx.globalCompositeOperation = 'source-over';
this.sharpCtx.globalAlpha = 1.0;
}
}
onProgress(40);
if (this.ctx && this.sharpCanvas) {
this.ctx.globalAlpha = 1.0;
this.ctx.drawImage(this.sharpCanvas, 0, 0);
if (overlayStrength > 0) {
this.ctx.globalCompositeOperation = 'overlay';
this.ctx.globalAlpha = overlayStrength;
this.ctx.drawImage(this.sharpCanvas, 0, 0);
this.ctx.globalCompositeOperation = 'source-over';
this.ctx.globalAlpha = 1.0;
}
}
const imageData = this.ctx!.getImageData(0, 0, width, height);
const pixels = imageData.data;
onProgress(50);
const lumMatrix = new Float32Array(width * height);
let minLum = 1.0, maxLum = 0.0;
if (colorOutput) {
this.colorData = new Uint8Array(width * height * 3);
}
for (let i = 0; i < width * height; i++) {
const offset = i * 4;
const r = pixels[offset];
const g = pixels[offset + 1];
const b = pixels[offset + 2];
let lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
if (colorOutput && this.colorData) {
this.colorData[i * 3] = r;
this.colorData[i * 3 + 1] = g;
this.colorData[i * 3 + 2] = b;
}
if (gamma !== 1.0) {
lum = Math.pow(lum, gamma);
}
if (options.invert) {
lum = 1 - lum;
}
lumMatrix[i] = lum;
if (lum < minLum) minLum = lum;
if (lum > maxLum) maxLum = lum;
}
onProgress(60);
const lumRange = maxLum - minLum;
if (autoStretch && lumRange > 0.01) {
for (let i = 0; i < lumMatrix.length; i++) {
lumMatrix[i] = (lumMatrix[i] - minLum) / lumRange;
}
}
if (dither) {
const levels = charSet.length;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = y * width + x;
const oldVal = lumMatrix[i];
const newVal = Math.round(oldVal * (levels - 1)) / (levels - 1);
lumMatrix[i] = newVal;
const error = oldVal - newVal;
if (x + 1 < width) lumMatrix[i + 1] += error * 7 / 16;
if (y + 1 < height) {
if (x > 0) lumMatrix[(y + 1) * width + (x - 1)] += error * 3 / 16;
lumMatrix[(y + 1) * width + x] += error * 5 / 16;
if (x + 1 < width) lumMatrix[(y + 1) * width + (x + 1)] += error * 1 / 16;
}
}
}
}
onProgress(80);
let output = '';
if (colorOutput && this.colorData) {
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = y * width + x;
const brightness = Math.max(0, Math.min(1, lumMatrix[i]));
const charIndex = Math.floor(brightness * (charSet.length - 1));
const safeIndex = Math.max(0, Math.min(charSet.length - 1, charIndex));
const char = charSet[safeIndex];
const r = this.colorData[i * 3];
const g = this.colorData[i * 3 + 1];
const b = this.colorData[i * 3 + 2];
const safeChar = char === '<' ? '&lt;' : char === '>' ? '&gt;' : char === '&' ? '&amp;' : char;
output += `<span style="color:rgb(${r},${g},${b})">${safeChar}</span>`;
}
output += '\n';
}
} else {
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const brightness = Math.max(0, Math.min(1, lumMatrix[y * width + x]));
const charIndex = Math.floor(brightness * (charSet.length - 1));
const safeIndex = Math.max(0, Math.min(charSet.length - 1, charIndex));
output += charSet[safeIndex];
}
output += '\n';
}
}
onProgress(100);
if (colorOutput) {
return {
output,
isHtml: true,
width,
height
};
}
return output;
}
private getImageRatio(img: HTMLImageElement): number {
if (img.width && img.height) {
return img.width / img.height;
}
return 1;
}
private resolveImage(src: string | HTMLImageElement): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
if (src instanceof HTMLImageElement) {
if (src.complete) return resolve(src);
src.onload = () => resolve(src);
src.onerror = reject;
return;
}
const img = new Image();
img.crossOrigin = 'Anonymous';
img.src = src;
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Failed to load image'));
});
}
}
export async function imageToAscii(imageSource: string | HTMLImageElement, options: AsciiOptions = {}): Promise<string | AsciiResult> {
const generator = new AsciiGenerator();
return generator.generate(imageSource, options);
}
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 : 1.0;
let recommendedCharSet: CharSetKey = 'standard';
let denoise = false;
let enhanceEdges = false;
let overlayStrength = 0.3;
const histogramPeaks = countHistogramPeaks(histogram, pixelCount);
const isHighContrast = activeRange > 180;
const isLowContrast = activeRange < 80;
const isBimodal = histogramPeaks <= 3;
if (isBimodal && activeRange > 150) {
recommendedCharSet = 'minimal';
enhanceEdges = true;
overlayStrength = 0.1;
} else if (isHighContrast) {
recommendedCharSet = 'blocks';
overlayStrength = 0.2;
} else if (isLowContrast) {
recommendedCharSet = 'simple';
denoise = true;
overlayStrength = 0.5;
} else if (activeRange > 100 && activeRange <= 180) {
recommendedCharSet = 'standard';
const noiseLevel = estimateNoiseLevel(pixels, size);
if (noiseLevel > 20) {
denoise = true;
}
}
if (meta?.has_fine_detail) {
recommendedCharSet = 'dots';
}
return {
exposure: parseFloat(exposure.toFixed(2)),
contrast,
invert,
gamma,
saturation: parseFloat(saturation.toFixed(1)),
charSet: recommendedCharSet,
denoise,
enhanceEdges,
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
View 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
View 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();
}
}
}

View File

@@ -12,8 +12,8 @@ export interface RenderOptions {
invert: boolean; invert: boolean;
color: boolean; color: boolean;
overlayStrength?: number; overlayStrength?: number;
enhanceEdges?: boolean; edgeMode?: number; // 0=none, 1=simple, 2=sobel, 3=canny
dither?: boolean; dither?: number;
denoise?: boolean; denoise?: boolean;
zoom?: number; zoom?: number;
zoomCenter?: { x: number; y: number }; zoomCenter?: { x: number; y: number };
@@ -36,7 +36,7 @@ export class WebGLAsciiRenderer {
private gl: WebGLRenderingContext; private gl: WebGLRenderingContext;
private program: WebGLProgram | null; 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 buffers: { position?: WebGLBuffer; texCoord?: WebGLBuffer };
private charAtlas: { width: number; height: number; charWidth: number; charHeight: number; count: number } | null; private charAtlas: { width: number; height: number; charWidth: number; charHeight: number; count: number } | null;
private charSet: string; private charSet: string;
@@ -61,6 +61,7 @@ export class WebGLAsciiRenderer {
this.fontFamily = "'JetBrains Mono', monospace"; this.fontFamily = "'JetBrains Mono', monospace";
this.init(); this.init();
this.loadBlueNoiseTexture();
} }
init() { init() {
@@ -84,7 +85,9 @@ export class WebGLAsciiRenderer {
uniform sampler2D u_image; uniform sampler2D u_image;
uniform sampler2D u_atlas; uniform sampler2D u_atlas;
uniform sampler2D u_blueNoise;
uniform float u_charCount; 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_gridSize; // cols, rows
uniform vec2 u_texSize; // atlas size uniform vec2 u_texSize; // atlas size
@@ -96,7 +99,9 @@ export class WebGLAsciiRenderer {
uniform bool u_invert; uniform bool u_invert;
uniform bool u_color; uniform bool u_color;
uniform float u_overlayStrength; 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 // Zoom & Magnifier
uniform float u_zoom; uniform float u_zoom;
@@ -107,6 +112,15 @@ export class WebGLAsciiRenderer {
uniform bool u_showMagnifier; uniform bool u_showMagnifier;
uniform float u_aspect; 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) { vec3 adjust(vec3 color) {
// Exposure // Exposure
color *= u_exposure; color *= u_exposure;
@@ -124,6 +138,59 @@ export class WebGLAsciiRenderer {
return clamp(color, 0.0, 1.0); 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() { void main() {
vec2 uv = v_texCoord; vec2 uv = v_texCoord;
@@ -148,27 +215,65 @@ export class WebGLAsciiRenderer {
vec2 uvInCell = fract(uv * u_gridSize); vec2 uvInCell = fract(uv * u_gridSize);
// Sample image at the center of the cell // 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 // Out of bounds check for zoomed UV
if (sampleUV.x < 0.0 || sampleUV.x > 1.0 || sampleUV.y < 0.0 || sampleUV.y > 1.0) { if (sampleUV.x < 0.0 || sampleUV.x > 1.0 || sampleUV.y < 0.0 || sampleUV.y > 1.0) {
discard; discard;
} }
vec3 color = texture2D(u_image, sampleUV).rgb; vec3 color;
// Edge Enhancement (Simple Laplacian-like check) // Denoise: 3x3 box blur (applied to the base sampling if enabled)
if (u_enhanceEdges) { if (u_denoise) {
vec2 texel = 1.0 / u_gridSize; color = getAverageColor(sampleUV, cellSize * 2.0);
vec3 center = texture2D(u_image, sampleUV).rgb; } else {
vec3 top = texture2D(u_image, sampleUV + vec2(0.0, -texel.y)).rgb; color = getAverageColor(sampleUV, cellSize);
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; // 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); 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)); float edgeLum = dot(edges, vec3(0.2126, 0.7152, 0.0722));
color = mix(color, color * (1.0 - edgeLum * 2.0), 0.5); 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 // Apply adjustments
@@ -189,6 +294,16 @@ export class WebGLAsciiRenderer {
// Calculate luminance // Calculate luminance
float luma = dot(color, vec3(0.2126, 0.7152, 0.0722)); 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) { if (u_invert) {
luma = 1.0 - luma; luma = 1.0 - luma;
} }
@@ -197,9 +312,11 @@ export class WebGLAsciiRenderer {
float charIndex = floor(luma * (u_charCount - 1.0) + 0.5); float charIndex = floor(luma * (u_charCount - 1.0) + 0.5);
// Sample character atlas // Sample character atlas
// Use u_charSizeUV to scale, instead of just 1.0/u_charCount
// x = charIndex * charWidthUV + uvInCell.x * charWidthUV
vec2 atlasUV = vec2( vec2 atlasUV = vec2(
(charIndex + uvInCell.x) / u_charCount, (charIndex + uvInCell.x) * u_charSizeUV.x,
uvInCell.y uvInCell.y * u_charSizeUV.y
); );
float charAlpha = texture2D(u_atlas, atlasUV).r; float charAlpha = texture2D(u_atlas, atlasUV).r;
@@ -245,9 +362,10 @@ export class WebGLAsciiRenderer {
if (!this.program) return; if (!this.program) return;
const gl = this.gl; const gl = this.gl;
const uniforms = [ 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_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_zoom', 'u_zoomCenter', 'u_mousePos',
'u_magnifierRadius', 'u_magnifierZoom', 'u_showMagnifier', 'u_aspect' 'u_magnifierRadius', 'u_magnifierZoom', 'u_showMagnifier', 'u_aspect'
]; ];
@@ -300,16 +418,32 @@ export class WebGLAsciiRenderer {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) return; if (!ctx) return;
const fontSize = 32; // Higher resolution for atlas const fontSize = 32; // Higher resolution for atlas
// Add padding to prevent bleeding
const padding = 4;
ctx.font = `${fontSize}px ${fontName}`; ctx.font = `${fontSize}px ${fontName}`;
// Measure first char to get dimensions // Measure first char to get dimensions
const metrics = ctx.measureText('W'); const metrics = ctx.measureText('W');
const charWidth = Math.ceil(metrics.width); const charContentWidth = Math.ceil(metrics.width);
const charHeight = fontSize * 1.2; const charContentHeight = Math.ceil(fontSize * 1.2);
canvas.width = charWidth * charSet.length; // Full cell size including padding
canvas.height = charHeight; 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.font = `${fontSize}px ${fontName}`;
ctx.fillStyle = 'white'; ctx.fillStyle = 'white';
@@ -317,7 +451,10 @@ export class WebGLAsciiRenderer {
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < charSet.length; i++) { 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; const gl = this.gl;
@@ -329,14 +466,17 @@ export class WebGLAsciiRenderer {
gl.bindTexture(gl.TEXTURE_2D, this.textures.atlas); gl.bindTexture(gl.TEXTURE_2D, this.textures.atlas);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas); 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_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_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.generateMipmap(gl.TEXTURE_2D);
this.charAtlas = { this.charAtlas = {
width: canvas.width, width: texWidth,
height: canvas.height, height: texHeight,
charWidth, charWidth,
charHeight, charHeight,
count: charSet.length count: charSet.length
@@ -360,6 +500,11 @@ export class WebGLAsciiRenderer {
this.updateAtlas(options.charSetContent, options.fontFamily || 'monospace'); this.updateAtlas(options.charSetContent, options.fontFamily || 'monospace');
if (this.charAtlas) { if (this.charAtlas) {
gl.uniform1f(u['u_charCount'], this.charAtlas.count); 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); 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_invert'], options.invert ? 1 : 0);
gl.uniform1i(u['u_color'], options.color ? 1 : 0); gl.uniform1i(u['u_color'], options.color ? 1 : 0);
gl.uniform1f(u['u_overlayStrength'], options.overlayStrength || 0.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 // Zoom & Magnifier
gl.uniform1f(u['u_zoom'], options.zoom || 1.0); 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); 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) { updateTexture(image: HTMLImageElement) {
if (this.lastImage === image && this.textures.image) return; if (this.lastImage === image && this.textures.image) return;
@@ -434,6 +608,12 @@ export class WebGLAsciiRenderer {
gl.activeTexture(gl.TEXTURE1); gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.textures.atlas); 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); gl.drawArrays(gl.TRIANGLES, 0, 6);
} }
@@ -444,6 +624,39 @@ export class WebGLAsciiRenderer {
this.draw(); 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 // Kept for backward compatibility or specialized updates
updateMagnifier(options: MagnifierOptions) { updateMagnifier(options: MagnifierOptions) {
const gl = this.gl; const gl = this.gl;