feat: Implement WebGL-based ASCII renderer with zoom and magnifier, and optimize image queue fetching.
This commit is contained in:
@@ -12,6 +12,7 @@ import TuiButton from "../components/TuiButton.astro";
|
||||
<div class="ascii-layer">
|
||||
<div id="loading">GENERATING...</div>
|
||||
<pre id="ascii-result">Preparing art...</pre>
|
||||
<canvas id="ascii-canvas"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Foreground Layer: Content -->
|
||||
@@ -203,9 +204,26 @@ import TuiButton from "../components/TuiButton.astro";
|
||||
autoTuneImage,
|
||||
CHAR_SETS,
|
||||
} from "../scripts/ascii.js";
|
||||
import { fetchRandomAnimeImage } from "../scripts/anime-api.js";
|
||||
import {
|
||||
fetchRandomAnimeImage,
|
||||
fetchMultipleAnimeImages,
|
||||
} from "../scripts/anime-api.js";
|
||||
import { WebGLAsciiRenderer } from "../scripts/webgl-ascii.js";
|
||||
|
||||
const generator = new AsciiGenerator();
|
||||
const canvas = document.getElementById(
|
||||
"ascii-canvas",
|
||||
) as HTMLCanvasElement;
|
||||
let webglRenderer: WebGLAsciiRenderer | null = null;
|
||||
|
||||
try {
|
||||
webglRenderer = new WebGLAsciiRenderer(canvas);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"WebGL renderer failed to initialize, falling back to CPU",
|
||||
e,
|
||||
);
|
||||
}
|
||||
|
||||
// State
|
||||
let currentImgUrl: string | null = null;
|
||||
@@ -236,7 +254,7 @@ import TuiButton from "../components/TuiButton.astro";
|
||||
"loading",
|
||||
) as HTMLDivElement;
|
||||
|
||||
if (!asciiResult || !loadingIndicator) {
|
||||
if (!asciiResult || !loadingIndicator || !canvas) {
|
||||
throw new Error("Critical UI elements missing");
|
||||
}
|
||||
|
||||
@@ -365,35 +383,175 @@ import TuiButton from "../components/TuiButton.astro";
|
||||
|
||||
heightRows = widthCols / (imgRatio / fontAspectRatio);
|
||||
|
||||
try {
|
||||
const result = await generator.generate(imgEl, {
|
||||
width: widthCols,
|
||||
height: Math.floor(heightRows),
|
||||
...currentSettings,
|
||||
});
|
||||
// Decide whether to use WebGL or CPU
|
||||
// We use WebGL primarily for color mode since it's the biggest performance hit
|
||||
// But we can also use it for everything if currentSettings.color is true
|
||||
const useWebGL = webglRenderer !== null;
|
||||
|
||||
// Handle color output (returns object) vs plain text (returns string)
|
||||
if (typeof result === "object" && result.isHtml) {
|
||||
asciiResult.innerHTML = result.output;
|
||||
if (useWebGL) {
|
||||
asciiResult.style.display = "none";
|
||||
canvas.style.display = "block";
|
||||
|
||||
// Calculate intended aspect ratio of the ASCII grid
|
||||
const gridAspect = (widthCols * fontAspectRatio) / heightRows;
|
||||
const screenW = window.innerWidth;
|
||||
const screenH = window.innerHeight;
|
||||
|
||||
// Available space with margins (matching CPU version's 0.9 scale)
|
||||
const maxW = screenW * 0.95;
|
||||
const maxH = screenH * 0.95;
|
||||
|
||||
let finalW, finalH;
|
||||
if (gridAspect > maxW / maxH) {
|
||||
finalW = maxW;
|
||||
finalH = maxW / gridAspect;
|
||||
} else {
|
||||
asciiResult.textContent = result as string;
|
||||
finalH = maxH;
|
||||
finalW = maxH * gridAspect;
|
||||
}
|
||||
|
||||
// Auto-fit font size
|
||||
const sizeW = (screenW * 0.9) / (widthCols * fontAspectRatio);
|
||||
const sizeH = (screenH * 0.9) / heightRows;
|
||||
const bestSize = Math.min(sizeW, sizeH);
|
||||
// Set canvas CSS size to fit centered and internal resolution for sharpness
|
||||
canvas.style.width = `${finalW}px`;
|
||||
canvas.style.height = `${finalH}px`;
|
||||
|
||||
asciiResult.style.fontSize = `${Math.max(4, bestSize).toFixed(2)}px`;
|
||||
asciiResult.style.opacity = "1";
|
||||
} catch (e) {
|
||||
console.error("Render error", e);
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = finalW * dpr;
|
||||
canvas.height = finalH * dpr;
|
||||
|
||||
const charSetContent =
|
||||
CHAR_SETS[currentSettings.charSet] || CHAR_SETS.standard;
|
||||
|
||||
webglRenderer!.render(imgEl, {
|
||||
width: widthCols,
|
||||
height: Math.floor(heightRows),
|
||||
charSetContent: charSetContent,
|
||||
...currentSettings,
|
||||
zoom: zoom,
|
||||
zoomCenter: zoomCenter,
|
||||
mousePos: mousePos,
|
||||
showMagnifier: showMagnifier,
|
||||
magnifierRadius: 0.03,
|
||||
magnifierZoom: 2.5,
|
||||
});
|
||||
|
||||
canvas.style.opacity = "1";
|
||||
} else {
|
||||
canvas.style.display = "none";
|
||||
asciiResult.style.display = "block";
|
||||
|
||||
try {
|
||||
const result = await generator.generate(imgEl, {
|
||||
width: widthCols,
|
||||
height: Math.floor(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 sizeW =
|
||||
(screenW * 0.9) / (widthCols * fontAspectRatio);
|
||||
const sizeH = (screenH * 0.9) / 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
generate();
|
||||
}
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
|
||||
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) {
|
||||
generate(); // Re-render to update magnifier position
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
heroWrapper.addEventListener("mouseleave", () => {
|
||||
if (showMagnifier) {
|
||||
showMagnifier = false;
|
||||
generate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Queue System
|
||||
const imageQueue: { data: any; imgElement: HTMLImageElement }[] = [];
|
||||
const TARGET_QUEUE_SIZE = 3;
|
||||
const TARGET_QUEUE_SIZE = 5;
|
||||
let isFetchingQueue = false;
|
||||
|
||||
async function fillQueue() {
|
||||
@@ -405,18 +563,31 @@ import TuiButton from "../components/TuiButton.astro";
|
||||
isFetchingQueue = true;
|
||||
|
||||
try {
|
||||
// Fetch one by one until full
|
||||
while (imageQueue.length < TARGET_QUEUE_SIZE) {
|
||||
const data = await fetchRandomAnimeImage();
|
||||
// Preload
|
||||
const img = await resolveImage(data.url);
|
||||
// Store
|
||||
imageQueue.push({
|
||||
data: data,
|
||||
imgElement: img,
|
||||
});
|
||||
// Brief pause to be nice to API
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
const needed = TARGET_QUEUE_SIZE - imageQueue.length;
|
||||
if (needed <= 0) return;
|
||||
|
||||
// Fetch multiple metadata entries at once (API allows this)
|
||||
const results = await fetchMultipleAnimeImages({
|
||||
amount: needed,
|
||||
});
|
||||
|
||||
// Load images SEQUENTIALLY with a small delay to avoid 429s from the CDN
|
||||
for (const data of results) {
|
||||
if (imageQueue.length >= TARGET_QUEUE_SIZE) break;
|
||||
try {
|
||||
const img = await resolveImage(data.url);
|
||||
imageQueue.push({
|
||||
data: data,
|
||||
imgElement: img,
|
||||
});
|
||||
updateQueueStatus();
|
||||
// Small staggered delay
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
} catch (e) {
|
||||
console.error("Failed to preload image", data.url, e);
|
||||
// If we hit an error (likely rate limit or CORS), wait longer
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Queue fill error", e);
|
||||
@@ -455,6 +626,10 @@ import TuiButton from "../components/TuiButton.astro";
|
||||
fillQueue();
|
||||
}
|
||||
|
||||
// Reset zoom on new image
|
||||
zoom = 1.0;
|
||||
zoomCenter = { x: 0.5, y: 0.5 };
|
||||
|
||||
// Reset auto mode and apply auto-detected settings
|
||||
invertMode = "auto";
|
||||
detectedInvert = suggestions.invert;
|
||||
@@ -698,6 +873,16 @@ import TuiButton from "../components/TuiButton.astro";
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
#ascii-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: none;
|
||||
image-rendering: pixelated;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
#loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
|
||||
Reference in New Issue
Block a user