feat: convert js modules to ts and optimize performance
This commit is contained in:
@@ -206,7 +206,7 @@ import TuiButton from "../components/TuiButton.astro";
|
||||
} from "../scripts/ascii.js";
|
||||
import {
|
||||
fetchRandomAnimeImage,
|
||||
fetchMultipleAnimeImages,
|
||||
loadSingleImage,
|
||||
} from "../scripts/anime-api.js";
|
||||
import { WebGLAsciiRenderer } from "../scripts/webgl-ascii.js";
|
||||
|
||||
@@ -215,19 +215,22 @@ import TuiButton from "../components/TuiButton.astro";
|
||||
"ascii-canvas",
|
||||
) 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 = {
|
||||
let currentSettings: Record<string, any> = {
|
||||
exposure: 1.0,
|
||||
contrast: 1.0,
|
||||
saturation: 1.2,
|
||||
@@ -246,6 +249,25 @@ import TuiButton from "../components/TuiButton.astro";
|
||||
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(
|
||||
"ascii-result",
|
||||
@@ -284,7 +306,7 @@ import TuiButton from "../components/TuiButton.astro";
|
||||
];
|
||||
sliderIds.forEach((id) => {
|
||||
const input = document.getElementById(id) as HTMLInputElement;
|
||||
const valueDisplay = document.getElementById(`val-${id}`);
|
||||
// removed unused valueDisplay
|
||||
if (input && currentSettings[id] !== undefined) {
|
||||
input.value = String(currentSettings[id]);
|
||||
input.dispatchEvent(new Event("input"));
|
||||
@@ -348,123 +370,187 @@ import TuiButton from "../components/TuiButton.astro";
|
||||
};
|
||||
currentSettings.invert = detectedInvert;
|
||||
updateUI();
|
||||
generate();
|
||||
|
||||
// Full update
|
||||
calculateGrid().then(() => {
|
||||
requestRender("all");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
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;
|
||||
const screenH = window.innerHeight;
|
||||
|
||||
// Available space
|
||||
const availW = screenW * (1 - marginRatio);
|
||||
|
||||
// We need to determine optimal Width (cols) and Font Size (px)
|
||||
let widthCols = screenW > 1000 ? 200 : 100; // Base resolution
|
||||
|
||||
// Calculate resulting height
|
||||
const imgEl = await resolveImage(currentImgUrl);
|
||||
const imgRatio = imgEl.width / imgEl.height;
|
||||
let heightRows = widthCols / (imgRatio / fontAspectRatio);
|
||||
|
||||
// Refined sizing logic (legacy-inspired)
|
||||
widthCols = Math.floor(availW / 6); // Assuming ~6px char width
|
||||
|
||||
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;
|
||||
|
||||
heightRows = widthCols / (imgRatio / fontAspectRatio);
|
||||
const imgEl = await resolveImage(currentImgUrl);
|
||||
const imgRatio = imgEl.width / imgEl.height;
|
||||
const heightRows = widthCols / (imgRatio / fontAspectRatio);
|
||||
|
||||
// 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;
|
||||
cachedGrid = {
|
||||
widthCols,
|
||||
heightRows,
|
||||
imgEl,
|
||||
};
|
||||
|
||||
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 {
|
||||
finalH = maxH;
|
||||
finalW = maxH * gridAspect;
|
||||
}
|
||||
|
||||
// Set canvas CSS size to fit centered and internal resolution for sharpness
|
||||
canvas.style.width = `${finalW}px`;
|
||||
canvas.style.height = `${finalH}px`;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = finalW * dpr;
|
||||
canvas.height = finalH * dpr;
|
||||
return cachedGrid;
|
||||
}
|
||||
|
||||
function renderLoop() {
|
||||
if (isWebGLAvailable && webglRenderer && cachedGrid.imgEl) {
|
||||
const charSetContent =
|
||||
CHAR_SETS[currentSettings.charSet] || CHAR_SETS.standard;
|
||||
CHAR_SETS[
|
||||
currentSettings.charSet as keyof typeof CHAR_SETS
|
||||
] || 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;
|
||||
// Only act if dirty
|
||||
if (dirtyTexture || dirtyGrid || dirtyUniforms) {
|
||||
if (dirtyTexture) {
|
||||
webglRenderer.updateTexture(cachedGrid.imgEl);
|
||||
}
|
||||
|
||||
// Auto-fit font size
|
||||
const sizeW =
|
||||
(screenW * 0.9) / (widthCols * fontAspectRatio);
|
||||
const sizeH = (screenH * 0.9) / heightRows;
|
||||
const bestSize = Math.min(sizeW, sizeH);
|
||||
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;
|
||||
|
||||
asciiResult.style.fontSize = `${Math.max(4, bestSize).toFixed(2)}px`;
|
||||
asciiResult.style.opacity = "1";
|
||||
} catch (e) {
|
||||
console.error("Render error", e);
|
||||
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
|
||||
@@ -517,12 +603,14 @@ import TuiButton from "../components/TuiButton.astro";
|
||||
zoomCenter.y = (imgY - my / zoom) / (1 - 1 / zoom);
|
||||
}
|
||||
|
||||
generate();
|
||||
requestRender("uniforms");
|
||||
}
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
|
||||
let magnifierTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
heroWrapper.addEventListener("mousemove", (e: any) => {
|
||||
if (webglRenderer) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
@@ -536,7 +624,7 @@ import TuiButton from "../components/TuiButton.astro";
|
||||
showMagnifier = mx >= 0 && mx <= 1 && my >= 0 && my <= 1;
|
||||
|
||||
if (showMagnifier || wasShowing) {
|
||||
generate(); // Re-render to update magnifier position
|
||||
requestRender("uniforms");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -544,55 +632,47 @@ import TuiButton from "../components/TuiButton.astro";
|
||||
heroWrapper.addEventListener("mouseleave", () => {
|
||||
if (showMagnifier) {
|
||||
showMagnifier = false;
|
||||
generate();
|
||||
requestRender("uniforms");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Queue System
|
||||
const imageQueue: { data: any; imgElement: HTMLImageElement }[] = [];
|
||||
const TARGET_QUEUE_SIZE = 5;
|
||||
let isFetchingQueue = false;
|
||||
let isFetchingNext = false;
|
||||
const MAX_QUEUE_SIZE = 2;
|
||||
|
||||
async function fillQueue() {
|
||||
if (isFetchingQueue || imageQueue.length >= TARGET_QUEUE_SIZE)
|
||||
return;
|
||||
// Prevent filling if document is hidden to save bandwidth
|
||||
async function prefetchNext() {
|
||||
if (isFetchingNext || imageQueue.length >= MAX_QUEUE_SIZE) return;
|
||||
if (document.hidden) return;
|
||||
|
||||
isFetchingQueue = true;
|
||||
isFetchingNext = true;
|
||||
|
||||
try {
|
||||
const needed = TARGET_QUEUE_SIZE - imageQueue.length;
|
||||
if (needed <= 0) return;
|
||||
const data = await fetchRandomAnimeImage();
|
||||
|
||||
// Fetch multiple metadata entries at once (API allows this)
|
||||
const results = await fetchMultipleAnimeImages({
|
||||
amount: needed,
|
||||
});
|
||||
loadingIndicator.style.display = "block";
|
||||
asciiResult.textContent = `FETCHING... (${imageQueue.length + 1}/${MAX_QUEUE_SIZE})`;
|
||||
asciiResult.style.opacity = "0.5";
|
||||
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
const img = await loadSingleImage(data.url);
|
||||
|
||||
imageQueue.push({ data, imgElement: img });
|
||||
updateQueueStatus();
|
||||
|
||||
loadingIndicator.style.display = "none";
|
||||
} catch (e) {
|
||||
console.error("Queue fill error", e);
|
||||
console.error("Failed to prefetch image:", e);
|
||||
loadingIndicator.style.display = "none";
|
||||
} finally {
|
||||
isFetchingQueue = false;
|
||||
isFetchingNext = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureQueueFilled() {
|
||||
while (imageQueue.length < MAX_QUEUE_SIZE) {
|
||||
await prefetchNext();
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,12 +686,16 @@ import TuiButton from "../components/TuiButton.astro";
|
||||
// If queue is empty, show loading and wait for fetch
|
||||
if (imageQueue.length === 0) {
|
||||
loadingIndicator.style.display = "block";
|
||||
asciiResult.style.opacity = "0.3";
|
||||
asciiResult.textContent = "FETCHING...";
|
||||
asciiResult.style.opacity = "0.5";
|
||||
|
||||
const data = await fetchRandomAnimeImage();
|
||||
const img = await resolveImage(data.url);
|
||||
const img = await loadSingleImage(data.url);
|
||||
currentImgUrl = data.url;
|
||||
|
||||
suggestions = autoTuneImage(img, data.meta);
|
||||
|
||||
loadingIndicator.style.display = "none";
|
||||
} else {
|
||||
// Pop from queue
|
||||
const nextItem = imageQueue.shift()!;
|
||||
@@ -623,7 +707,7 @@ import TuiButton from "../components/TuiButton.astro";
|
||||
);
|
||||
|
||||
// Trigger refill in background
|
||||
fillQueue();
|
||||
ensureQueueFilled();
|
||||
}
|
||||
|
||||
// Reset zoom on new image
|
||||
@@ -689,7 +773,11 @@ import TuiButton from "../components/TuiButton.astro";
|
||||
if (input) {
|
||||
input.addEventListener("input", () => {
|
||||
currentSettings[id] = parseFloat(input.value);
|
||||
generate();
|
||||
if (id === "resolution") {
|
||||
calculateGrid().then(() => requestRender("grid"));
|
||||
} else {
|
||||
requestRender("uniforms");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -705,19 +793,19 @@ import TuiButton from "../components/TuiButton.astro";
|
||||
switch (toggleId) {
|
||||
case "toggle-color":
|
||||
currentSettings.color = checked;
|
||||
generate();
|
||||
requestRender("uniforms");
|
||||
break;
|
||||
case "toggle-dither":
|
||||
currentSettings.dither = checked;
|
||||
generate();
|
||||
requestRender("uniforms");
|
||||
break;
|
||||
case "toggle-denoise":
|
||||
currentSettings.denoise = checked;
|
||||
generate();
|
||||
requestRender("uniforms");
|
||||
break;
|
||||
case "toggle-edges":
|
||||
currentSettings.enhanceEdges = checked;
|
||||
generate();
|
||||
requestRender("uniforms");
|
||||
break;
|
||||
}
|
||||
});
|
||||
@@ -737,7 +825,7 @@ import TuiButton from "../components/TuiButton.astro";
|
||||
invertMode = "off";
|
||||
currentSettings.invert = false;
|
||||
}
|
||||
generate();
|
||||
requestRender("uniforms");
|
||||
});
|
||||
|
||||
document
|
||||
@@ -745,7 +833,7 @@ import TuiButton from "../components/TuiButton.astro";
|
||||
?.addEventListener("segment-change", (e: any) => {
|
||||
const shortKey = e.detail.value;
|
||||
currentSettings.charSet = charSetKeyMap[shortKey] || "standard";
|
||||
generate();
|
||||
requestRender("uniforms"); // Charset update uses updateUniforms -> updateAtlas
|
||||
});
|
||||
|
||||
// Action button events
|
||||
@@ -777,8 +865,7 @@ import TuiButton from "../components/TuiButton.astro";
|
||||
break;
|
||||
case "i": // Cycle invert (AUTO -> ON -> OFF -> AUTO)
|
||||
{
|
||||
const invertSegment =
|
||||
document.getElementById("segment-invert");
|
||||
// removed unused invertSegment declaration
|
||||
if (invertMode === "auto") {
|
||||
invertMode = "on";
|
||||
currentSettings.invert = true;
|
||||
@@ -790,24 +877,24 @@ import TuiButton from "../components/TuiButton.astro";
|
||||
currentSettings.invert = detectedInvert;
|
||||
}
|
||||
updateUI();
|
||||
generate();
|
||||
requestRender("uniforms");
|
||||
}
|
||||
break;
|
||||
case "c": // Toggle color
|
||||
currentSettings.color = !currentSettings.color;
|
||||
updateUI();
|
||||
generate();
|
||||
requestRender("uniforms");
|
||||
break;
|
||||
case "d": // Toggle dither
|
||||
currentSettings.dither = !currentSettings.dither;
|
||||
updateUI();
|
||||
generate();
|
||||
requestRender("uniforms");
|
||||
break;
|
||||
case "e": // Toggle edges
|
||||
currentSettings.enhanceEdges =
|
||||
!currentSettings.enhanceEdges;
|
||||
updateUI();
|
||||
generate();
|
||||
requestRender("uniforms");
|
||||
break;
|
||||
case "s": // Cycle charset
|
||||
{
|
||||
@@ -816,7 +903,7 @@ import TuiButton from "../components/TuiButton.astro";
|
||||
const nextIdx = (idx + 1) % keys.length;
|
||||
currentSettings.charSet = keys[nextIdx];
|
||||
updateUI();
|
||||
generate();
|
||||
requestRender("uniforms");
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -834,7 +921,7 @@ import TuiButton from "../components/TuiButton.astro";
|
||||
|
||||
// Init
|
||||
loadNewImage().then(() => {
|
||||
fillQueue(); // Start filling queue after first load
|
||||
ensureQueueFilled(); // Start filling queue after first load
|
||||
});
|
||||
</script>
|
||||
</Layout>
|
||||
|
||||
Reference in New Issue
Block a user