feat: convert js modules to ts and optimize performance
This commit is contained in:
@@ -14,7 +14,7 @@ const { id, label, options, value = options[0], title = "" } = Astro.props;
|
|||||||
<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}>
|
||||||
{
|
{
|
||||||
options.map((opt, i) => (
|
options.map((opt) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class:list={[
|
class:list={[
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ params, request }) => {
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
const path = params.path;
|
const url = new URL(request.url);
|
||||||
if (!path) {
|
const targetUrl = url.searchParams.get('url');
|
||||||
return new Response('Missing path', { status: 400 });
|
|
||||||
|
if (!targetUrl) {
|
||||||
|
return new Response('Missing url parameter', { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetUrl = path.startsWith('http') ? path : `https://${path}`;
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const search = url.search; // keep query params
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${targetUrl}${search}`, {
|
const response = await fetch(targetUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'Mozilla/5.0',
|
'User-Agent': 'Mozilla/5.0',
|
||||||
// Optional: forward other safe headers if needed
|
// Optional: forward other safe headers if needed
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ import TuiButton from "../components/TuiButton.astro";
|
|||||||
} from "../scripts/ascii.js";
|
} from "../scripts/ascii.js";
|
||||||
import {
|
import {
|
||||||
fetchRandomAnimeImage,
|
fetchRandomAnimeImage,
|
||||||
fetchMultipleAnimeImages,
|
loadSingleImage,
|
||||||
} from "../scripts/anime-api.js";
|
} from "../scripts/anime-api.js";
|
||||||
import { WebGLAsciiRenderer } from "../scripts/webgl-ascii.js";
|
import { WebGLAsciiRenderer } from "../scripts/webgl-ascii.js";
|
||||||
|
|
||||||
@@ -215,19 +215,22 @@ import TuiButton from "../components/TuiButton.astro";
|
|||||||
"ascii-canvas",
|
"ascii-canvas",
|
||||||
) as HTMLCanvasElement;
|
) as HTMLCanvasElement;
|
||||||
let webglRenderer: WebGLAsciiRenderer | null = null;
|
let webglRenderer: WebGLAsciiRenderer | null = null;
|
||||||
|
let isWebGLAvailable = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
webglRenderer = new WebGLAsciiRenderer(canvas);
|
webglRenderer = new WebGLAsciiRenderer(canvas);
|
||||||
|
isWebGLAvailable = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"WebGL renderer failed to initialize, falling back to CPU",
|
"WebGL renderer failed to initialize, falling back to CPU",
|
||||||
e,
|
e,
|
||||||
);
|
);
|
||||||
|
isWebGLAvailable = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// State
|
// State
|
||||||
let currentImgUrl: string | null = null;
|
let currentImgUrl: string | null = null;
|
||||||
let currentSettings = {
|
let currentSettings: Record<string, any> = {
|
||||||
exposure: 1.0,
|
exposure: 1.0,
|
||||||
contrast: 1.0,
|
contrast: 1.0,
|
||||||
saturation: 1.2,
|
saturation: 1.2,
|
||||||
@@ -246,6 +249,25 @@ import TuiButton from "../components/TuiButton.astro";
|
|||||||
let detectedInvert = false;
|
let detectedInvert = false;
|
||||||
let detectedSettings: any = {}; // Store auto-detected settings
|
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
|
// DOM Elements
|
||||||
const asciiResult = document.getElementById(
|
const asciiResult = document.getElementById(
|
||||||
"ascii-result",
|
"ascii-result",
|
||||||
@@ -284,7 +306,7 @@ import TuiButton from "../components/TuiButton.astro";
|
|||||||
];
|
];
|
||||||
sliderIds.forEach((id) => {
|
sliderIds.forEach((id) => {
|
||||||
const input = document.getElementById(id) as HTMLInputElement;
|
const input = document.getElementById(id) as HTMLInputElement;
|
||||||
const valueDisplay = document.getElementById(`val-${id}`);
|
// removed unused valueDisplay
|
||||||
if (input && currentSettings[id] !== undefined) {
|
if (input && currentSettings[id] !== undefined) {
|
||||||
input.value = String(currentSettings[id]);
|
input.value = String(currentSettings[id]);
|
||||||
input.dispatchEvent(new Event("input"));
|
input.dispatchEvent(new Event("input"));
|
||||||
@@ -348,56 +370,83 @@ import TuiButton from "../components/TuiButton.astro";
|
|||||||
};
|
};
|
||||||
currentSettings.invert = detectedInvert;
|
currentSettings.invert = detectedInvert;
|
||||||
updateUI();
|
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;
|
if (!currentImgUrl) return;
|
||||||
|
|
||||||
// Dynamic sizing logic to fit screen
|
// Dynamic sizing logic to fit screen
|
||||||
const fontAspectRatio = 0.55;
|
const fontAspectRatio = 0.55;
|
||||||
const marginRatio = 0.2;
|
const marginRatio = 0.2;
|
||||||
const screenW = window.innerWidth;
|
const screenW = window.innerWidth;
|
||||||
const screenH = window.innerHeight;
|
|
||||||
|
|
||||||
// Available space
|
// Available space
|
||||||
const availW = screenW * (1 - marginRatio);
|
const availW = screenW * (1 - marginRatio);
|
||||||
|
|
||||||
// We need to determine optimal Width (cols) and Font Size (px)
|
let widthCols = Math.floor(availW / 6); // Assuming ~6px char width
|
||||||
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
|
|
||||||
|
|
||||||
// Apply resolution scaling
|
// Apply resolution scaling
|
||||||
widthCols = Math.floor(widthCols * currentSettings.resolution);
|
widthCols = Math.floor(widthCols * currentSettings.resolution);
|
||||||
|
|
||||||
if (widthCols > 300) widthCols = 300; // Cap to prevent crashing
|
if (widthCols > 300) widthCols = 300; // Cap to prevent crashing
|
||||||
if (widthCols < 40) widthCols = 40;
|
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
|
cachedGrid = {
|
||||||
// We use WebGL primarily for color mode since it's the biggest performance hit
|
widthCols,
|
||||||
// But we can also use it for everything if currentSettings.color is true
|
heightRows,
|
||||||
const useWebGL = webglRenderer !== null;
|
imgEl,
|
||||||
|
};
|
||||||
|
|
||||||
if (useWebGL) {
|
return cachedGrid;
|
||||||
asciiResult.style.display = "none";
|
}
|
||||||
canvas.style.display = "block";
|
|
||||||
|
|
||||||
// Calculate intended aspect ratio of the ASCII grid
|
function renderLoop() {
|
||||||
const gridAspect = (widthCols * fontAspectRatio) / heightRows;
|
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 screenW = window.innerWidth;
|
||||||
const screenH = window.innerHeight;
|
const screenH = window.innerHeight;
|
||||||
|
|
||||||
// Available space with margins (matching CPU version's 0.9 scale)
|
|
||||||
const maxW = screenW * 0.95;
|
const maxW = screenW * 0.95;
|
||||||
const maxH = screenH * 0.95;
|
const maxH = screenH * 0.95;
|
||||||
|
|
||||||
@@ -410,39 +459,61 @@ import TuiButton from "../components/TuiButton.astro";
|
|||||||
finalW = maxH * gridAspect;
|
finalW = maxH * gridAspect;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set canvas CSS size to fit centered and internal resolution for sharpness
|
|
||||||
canvas.style.width = `${finalW}px`;
|
canvas.style.width = `${finalW}px`;
|
||||||
canvas.style.height = `${finalH}px`;
|
canvas.style.height = `${finalH}px`;
|
||||||
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
canvas.width = finalW * dpr;
|
canvas.width = finalW * dpr;
|
||||||
canvas.height = finalH * dpr;
|
canvas.height = finalH * dpr;
|
||||||
|
|
||||||
const charSetContent =
|
webglRenderer.updateGrid(
|
||||||
CHAR_SETS[currentSettings.charSet] || CHAR_SETS.standard;
|
cachedGrid.widthCols,
|
||||||
|
Math.floor(cachedGrid.heightRows),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
webglRenderer!.render(imgEl, {
|
if (dirtyUniforms || dirtyGrid) {
|
||||||
width: widthCols,
|
// Uniforms often depend on grid/atlas state
|
||||||
height: Math.floor(heightRows),
|
webglRenderer.updateUniforms({
|
||||||
|
width: cachedGrid.widthCols,
|
||||||
|
height: Math.floor(cachedGrid.heightRows),
|
||||||
charSetContent: charSetContent,
|
charSetContent: charSetContent,
|
||||||
...currentSettings,
|
...currentSettings,
|
||||||
|
dither: currentSettings.dither,
|
||||||
|
denoise: currentSettings.denoise,
|
||||||
|
// WebGLAsciiRenderer handles Defaults for zoom/magnifier if undefined
|
||||||
zoom: zoom,
|
zoom: zoom,
|
||||||
zoomCenter: zoomCenter,
|
zoomCenter: zoomCenter,
|
||||||
mousePos: mousePos,
|
mousePos: mousePos,
|
||||||
showMagnifier: showMagnifier,
|
showMagnifier: showMagnifier,
|
||||||
magnifierRadius: 0.03,
|
magnifierRadius: 0.15,
|
||||||
magnifierZoom: 2.5,
|
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.opacity = "1";
|
|
||||||
} else {
|
|
||||||
canvas.style.display = "none";
|
canvas.style.display = "none";
|
||||||
asciiResult.style.display = "block";
|
asciiResult.style.display = "block";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await generator.generate(imgEl, {
|
const result = await generator.generate(cachedGrid.imgEl, {
|
||||||
width: widthCols,
|
width: cachedGrid.widthCols,
|
||||||
height: Math.floor(heightRows),
|
height: Math.floor(cachedGrid.heightRows),
|
||||||
...currentSettings,
|
...currentSettings,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -454,9 +525,12 @@ import TuiButton from "../components/TuiButton.astro";
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-fit font size
|
// Auto-fit font size
|
||||||
|
const fontAspectRatio = 0.55;
|
||||||
|
const screenW = window.innerWidth;
|
||||||
|
const screenH = window.innerHeight;
|
||||||
const sizeW =
|
const sizeW =
|
||||||
(screenW * 0.9) / (widthCols * fontAspectRatio);
|
(screenW * 0.9) / (cachedGrid.widthCols * fontAspectRatio);
|
||||||
const sizeH = (screenH * 0.9) / heightRows;
|
const sizeH = (screenH * 0.9) / cachedGrid.heightRows;
|
||||||
const bestSize = Math.min(sizeW, sizeH);
|
const bestSize = Math.min(sizeW, sizeH);
|
||||||
|
|
||||||
asciiResult.style.fontSize = `${Math.max(4, bestSize).toFixed(2)}px`;
|
asciiResult.style.fontSize = `${Math.max(4, bestSize).toFixed(2)}px`;
|
||||||
@@ -465,6 +539,18 @@ import TuiButton from "../components/TuiButton.astro";
|
|||||||
console.error("Render error", 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
|
// Zoom & Magnifier State
|
||||||
@@ -517,12 +603,14 @@ import TuiButton from "../components/TuiButton.astro";
|
|||||||
zoomCenter.y = (imgY - my / zoom) / (1 - 1 / zoom);
|
zoomCenter.y = (imgY - my / zoom) / (1 - 1 / zoom);
|
||||||
}
|
}
|
||||||
|
|
||||||
generate();
|
requestRender("uniforms");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ passive: false },
|
{ passive: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let magnifierTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
heroWrapper.addEventListener("mousemove", (e: any) => {
|
heroWrapper.addEventListener("mousemove", (e: any) => {
|
||||||
if (webglRenderer) {
|
if (webglRenderer) {
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
@@ -536,7 +624,7 @@ import TuiButton from "../components/TuiButton.astro";
|
|||||||
showMagnifier = mx >= 0 && mx <= 1 && my >= 0 && my <= 1;
|
showMagnifier = mx >= 0 && mx <= 1 && my >= 0 && my <= 1;
|
||||||
|
|
||||||
if (showMagnifier || wasShowing) {
|
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", () => {
|
heroWrapper.addEventListener("mouseleave", () => {
|
||||||
if (showMagnifier) {
|
if (showMagnifier) {
|
||||||
showMagnifier = false;
|
showMagnifier = false;
|
||||||
generate();
|
requestRender("uniforms");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue System
|
// Queue System
|
||||||
const imageQueue: { data: any; imgElement: HTMLImageElement }[] = [];
|
const imageQueue: { data: any; imgElement: HTMLImageElement }[] = [];
|
||||||
const TARGET_QUEUE_SIZE = 5;
|
let isFetchingNext = false;
|
||||||
let isFetchingQueue = false;
|
const MAX_QUEUE_SIZE = 2;
|
||||||
|
|
||||||
async function fillQueue() {
|
async function prefetchNext() {
|
||||||
if (isFetchingQueue || imageQueue.length >= TARGET_QUEUE_SIZE)
|
if (isFetchingNext || imageQueue.length >= MAX_QUEUE_SIZE) return;
|
||||||
return;
|
|
||||||
// Prevent filling if document is hidden to save bandwidth
|
|
||||||
if (document.hidden) return;
|
if (document.hidden) return;
|
||||||
|
|
||||||
isFetchingQueue = true;
|
isFetchingNext = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const needed = TARGET_QUEUE_SIZE - imageQueue.length;
|
const data = await fetchRandomAnimeImage();
|
||||||
if (needed <= 0) return;
|
|
||||||
|
|
||||||
// Fetch multiple metadata entries at once (API allows this)
|
loadingIndicator.style.display = "block";
|
||||||
const results = await fetchMultipleAnimeImages({
|
asciiResult.textContent = `FETCHING... (${imageQueue.length + 1}/${MAX_QUEUE_SIZE})`;
|
||||||
amount: needed,
|
asciiResult.style.opacity = "0.5";
|
||||||
});
|
|
||||||
|
|
||||||
// Load images SEQUENTIALLY with a small delay to avoid 429s from the CDN
|
const img = await loadSingleImage(data.url);
|
||||||
for (const data of results) {
|
|
||||||
if (imageQueue.length >= TARGET_QUEUE_SIZE) break;
|
imageQueue.push({ data, imgElement: img });
|
||||||
try {
|
|
||||||
const img = await resolveImage(data.url);
|
|
||||||
imageQueue.push({
|
|
||||||
data: data,
|
|
||||||
imgElement: img,
|
|
||||||
});
|
|
||||||
updateQueueStatus();
|
updateQueueStatus();
|
||||||
// Small staggered delay
|
|
||||||
await new Promise((r) => setTimeout(r, 300));
|
loadingIndicator.style.display = "none";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to preload image", data.url, e);
|
console.error("Failed to prefetch image:", e);
|
||||||
// If we hit an error (likely rate limit or CORS), wait longer
|
loadingIndicator.style.display = "none";
|
||||||
await new Promise((r) => setTimeout(r, 2000));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Queue fill error", e);
|
|
||||||
} finally {
|
} 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 queue is empty, show loading and wait for fetch
|
||||||
if (imageQueue.length === 0) {
|
if (imageQueue.length === 0) {
|
||||||
loadingIndicator.style.display = "block";
|
loadingIndicator.style.display = "block";
|
||||||
asciiResult.style.opacity = "0.3";
|
asciiResult.textContent = "FETCHING...";
|
||||||
|
asciiResult.style.opacity = "0.5";
|
||||||
|
|
||||||
const data = await fetchRandomAnimeImage();
|
const data = await fetchRandomAnimeImage();
|
||||||
const img = await resolveImage(data.url);
|
const img = await loadSingleImage(data.url);
|
||||||
currentImgUrl = data.url;
|
currentImgUrl = data.url;
|
||||||
|
|
||||||
suggestions = autoTuneImage(img, data.meta);
|
suggestions = autoTuneImage(img, data.meta);
|
||||||
|
|
||||||
|
loadingIndicator.style.display = "none";
|
||||||
} else {
|
} else {
|
||||||
// Pop from queue
|
// Pop from queue
|
||||||
const nextItem = imageQueue.shift()!;
|
const nextItem = imageQueue.shift()!;
|
||||||
@@ -623,7 +707,7 @@ import TuiButton from "../components/TuiButton.astro";
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Trigger refill in background
|
// Trigger refill in background
|
||||||
fillQueue();
|
ensureQueueFilled();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset zoom on new image
|
// Reset zoom on new image
|
||||||
@@ -689,7 +773,11 @@ import TuiButton from "../components/TuiButton.astro";
|
|||||||
if (input) {
|
if (input) {
|
||||||
input.addEventListener("input", () => {
|
input.addEventListener("input", () => {
|
||||||
currentSettings[id] = parseFloat(input.value);
|
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) {
|
switch (toggleId) {
|
||||||
case "toggle-color":
|
case "toggle-color":
|
||||||
currentSettings.color = checked;
|
currentSettings.color = checked;
|
||||||
generate();
|
requestRender("uniforms");
|
||||||
break;
|
break;
|
||||||
case "toggle-dither":
|
case "toggle-dither":
|
||||||
currentSettings.dither = checked;
|
currentSettings.dither = checked;
|
||||||
generate();
|
requestRender("uniforms");
|
||||||
break;
|
break;
|
||||||
case "toggle-denoise":
|
case "toggle-denoise":
|
||||||
currentSettings.denoise = checked;
|
currentSettings.denoise = checked;
|
||||||
generate();
|
requestRender("uniforms");
|
||||||
break;
|
break;
|
||||||
case "toggle-edges":
|
case "toggle-edges":
|
||||||
currentSettings.enhanceEdges = checked;
|
currentSettings.enhanceEdges = checked;
|
||||||
generate();
|
requestRender("uniforms");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -737,7 +825,7 @@ import TuiButton from "../components/TuiButton.astro";
|
|||||||
invertMode = "off";
|
invertMode = "off";
|
||||||
currentSettings.invert = false;
|
currentSettings.invert = false;
|
||||||
}
|
}
|
||||||
generate();
|
requestRender("uniforms");
|
||||||
});
|
});
|
||||||
|
|
||||||
document
|
document
|
||||||
@@ -745,7 +833,7 @@ import TuiButton from "../components/TuiButton.astro";
|
|||||||
?.addEventListener("segment-change", (e: any) => {
|
?.addEventListener("segment-change", (e: any) => {
|
||||||
const shortKey = e.detail.value;
|
const shortKey = e.detail.value;
|
||||||
currentSettings.charSet = charSetKeyMap[shortKey] || "standard";
|
currentSettings.charSet = charSetKeyMap[shortKey] || "standard";
|
||||||
generate();
|
requestRender("uniforms"); // Charset update uses updateUniforms -> updateAtlas
|
||||||
});
|
});
|
||||||
|
|
||||||
// Action button events
|
// Action button events
|
||||||
@@ -777,8 +865,7 @@ import TuiButton from "../components/TuiButton.astro";
|
|||||||
break;
|
break;
|
||||||
case "i": // Cycle invert (AUTO -> ON -> OFF -> AUTO)
|
case "i": // Cycle invert (AUTO -> ON -> OFF -> AUTO)
|
||||||
{
|
{
|
||||||
const invertSegment =
|
// removed unused invertSegment declaration
|
||||||
document.getElementById("segment-invert");
|
|
||||||
if (invertMode === "auto") {
|
if (invertMode === "auto") {
|
||||||
invertMode = "on";
|
invertMode = "on";
|
||||||
currentSettings.invert = true;
|
currentSettings.invert = true;
|
||||||
@@ -790,24 +877,24 @@ import TuiButton from "../components/TuiButton.astro";
|
|||||||
currentSettings.invert = detectedInvert;
|
currentSettings.invert = detectedInvert;
|
||||||
}
|
}
|
||||||
updateUI();
|
updateUI();
|
||||||
generate();
|
requestRender("uniforms");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "c": // Toggle color
|
case "c": // Toggle color
|
||||||
currentSettings.color = !currentSettings.color;
|
currentSettings.color = !currentSettings.color;
|
||||||
updateUI();
|
updateUI();
|
||||||
generate();
|
requestRender("uniforms");
|
||||||
break;
|
break;
|
||||||
case "d": // Toggle dither
|
case "d": // Toggle dither
|
||||||
currentSettings.dither = !currentSettings.dither;
|
currentSettings.dither = !currentSettings.dither;
|
||||||
updateUI();
|
updateUI();
|
||||||
generate();
|
requestRender("uniforms");
|
||||||
break;
|
break;
|
||||||
case "e": // Toggle edges
|
case "e": // Toggle edges
|
||||||
currentSettings.enhanceEdges =
|
currentSettings.enhanceEdges =
|
||||||
!currentSettings.enhanceEdges;
|
!currentSettings.enhanceEdges;
|
||||||
updateUI();
|
updateUI();
|
||||||
generate();
|
requestRender("uniforms");
|
||||||
break;
|
break;
|
||||||
case "s": // Cycle charset
|
case "s": // Cycle charset
|
||||||
{
|
{
|
||||||
@@ -816,7 +903,7 @@ import TuiButton from "../components/TuiButton.astro";
|
|||||||
const nextIdx = (idx + 1) % keys.length;
|
const nextIdx = (idx + 1) % keys.length;
|
||||||
currentSettings.charSet = keys[nextIdx];
|
currentSettings.charSet = keys[nextIdx];
|
||||||
updateUI();
|
updateUI();
|
||||||
generate();
|
requestRender("uniforms");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -834,7 +921,7 @@ import TuiButton from "../components/TuiButton.astro";
|
|||||||
|
|
||||||
// Init
|
// Init
|
||||||
loadNewImage().then(() => {
|
loadNewImage().then(() => {
|
||||||
fillQueue(); // Start filling queue after first load
|
ensureQueueFilled(); // Start filling queue after first load
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
/**
|
|
||||||
* @typedef {Object} AnimeImage
|
|
||||||
* @property {string} url - The URL of the image.
|
|
||||||
* @property {string} [artist] - The artist name if available.
|
|
||||||
* @property {string} [sourceUrl] - The source URL of the artwork.
|
|
||||||
* @property {Object} [meta] - Original metadata object from the API.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Available categories from nekos.best API.
|
|
||||||
* All images are SFW.
|
|
||||||
* @type {readonly string[]}
|
|
||||||
*/
|
|
||||||
export const CATEGORIES = [
|
|
||||||
'waifu', 'neko', 'kitsune', 'husbando'
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches a random anime image from the nekos.best API.
|
|
||||||
* All images from this API are guaranteed SFW.
|
|
||||||
*
|
|
||||||
* @param {Object} options - Fetch options.
|
|
||||||
* @param {string} [options.category='waifu'] - Image category ('waifu', 'neko', 'kitsune', 'husbando').
|
|
||||||
* @param {number} [options.amount=1] - Number of images to fetch (1-20).
|
|
||||||
* @returns {Promise<AnimeImage>} The fetched image data.
|
|
||||||
*/
|
|
||||||
export async function fetchRandomAnimeImage(options = {}) {
|
|
||||||
const {
|
|
||||||
category = 'waifu',
|
|
||||||
amount = 1
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
// Validate amount (API allows 1-20)
|
|
||||||
const validAmount = Math.max(1, Math.min(20, amount));
|
|
||||||
|
|
||||||
// nekos.best API base URL
|
|
||||||
const apiBase = 'https://nekos.best/api/v2';
|
|
||||||
|
|
||||||
// Construct URL with category and optional amount
|
|
||||||
let url = `${apiBase}/${category}`;
|
|
||||||
if (validAmount > 1) {
|
|
||||||
url += `?amount=${validAmount}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`API Error: ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Validate response structure
|
|
||||||
// Expected: { results: [{ url, artist_name, artist_href, source_url }] }
|
|
||||||
if (!data.results || !Array.isArray(data.results) || data.results.length === 0) {
|
|
||||||
throw new Error('Invalid API response format: No results found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const image = data.results[0];
|
|
||||||
|
|
||||||
if (!image.url) {
|
|
||||||
throw new Error('Invalid API response format: No image URL found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
url: image.url,
|
|
||||||
artist: image.artist_name || undefined,
|
|
||||||
sourceUrl: image.source_url || undefined,
|
|
||||||
meta: image
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch anime image:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches multiple random anime images from the nekos.best API.
|
|
||||||
* All images from this API are guaranteed SFW.
|
|
||||||
*
|
|
||||||
* @param {Object} options - Fetch options.
|
|
||||||
* @param {string} [options.category='waifu'] - Image category ('waifu', 'neko', 'kitsune', 'husbando').
|
|
||||||
* @param {number} [options.amount=5] - Number of images to fetch (1-20).
|
|
||||||
* @returns {Promise<AnimeImage[]>} Array of fetched image data.
|
|
||||||
*/
|
|
||||||
export async function fetchMultipleAnimeImages(options = {}) {
|
|
||||||
const {
|
|
||||||
category = 'waifu',
|
|
||||||
amount = 5
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
// Validate amount (API allows 1-20)
|
|
||||||
const validAmount = Math.max(1, Math.min(20, amount));
|
|
||||||
|
|
||||||
const apiBase = 'https://nekos.best/api/v2';
|
|
||||||
const url = `${apiBase}/${category}?amount=${validAmount}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`API Error: ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!data.results || !Array.isArray(data.results)) {
|
|
||||||
throw new Error('Invalid API response format: No results found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.results.map(image => ({
|
|
||||||
url: image.url,
|
|
||||||
artist: image.artist_name || undefined,
|
|
||||||
sourceUrl: image.source_url || undefined,
|
|
||||||
meta: image
|
|
||||||
}));
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch anime images:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
75
src/scripts/anime-api.ts
Normal file
75
src/scripts/anime-api.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { fetchAnimeImage, loadImage, clearCache, AnimeImageResult } from './image-loader.js';
|
||||||
|
|
||||||
|
export type { AnimeImageResult };
|
||||||
|
|
||||||
|
export const CATEGORIES = ['waifu'] as const;
|
||||||
|
|
||||||
|
export type Category = typeof CATEGORIES[number];
|
||||||
|
|
||||||
|
export interface FetchOptions {
|
||||||
|
category?: Category;
|
||||||
|
retries?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FetchMultipleOptions {
|
||||||
|
category?: Category;
|
||||||
|
amount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
const BASE_RETRY_DELAY = 2000;
|
||||||
|
|
||||||
|
export async function fetchRandomAnimeImage(options: FetchOptions = {}): Promise<AnimeImageResult> {
|
||||||
|
const { category = 'waifu', retries = 0 } = options;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await fetchAnimeImage({ category });
|
||||||
|
} catch (error) {
|
||||||
|
if (retries < MAX_RETRIES) {
|
||||||
|
const delay = BASE_RETRY_DELAY * Math.pow(2, retries);
|
||||||
|
console.warn(`Failed to fetch image. Retrying in ${delay}ms...`);
|
||||||
|
if (retries === 0) {
|
||||||
|
clearCache();
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
return fetchRandomAnimeImage({ category, retries: retries + 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Failed to fetch anime image after all retries:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMultipleAnimeImages(options: FetchMultipleOptions = {}): Promise<AnimeImageResult[]> {
|
||||||
|
const { category = 'waifu', amount = 5 } = options;
|
||||||
|
|
||||||
|
const results: AnimeImageResult[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < amount; i++) {
|
||||||
|
try {
|
||||||
|
const image = await fetchRandomAnimeImage({ category });
|
||||||
|
results.push(image);
|
||||||
|
|
||||||
|
if (i < amount - 1) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch image ${i + 1}/${amount}:`, error);
|
||||||
|
if (results.length === 0) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSingleImage(url: string): Promise<HTMLImageElement> {
|
||||||
|
try {
|
||||||
|
return await loadImage(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load image:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,615 +0,0 @@
|
|||||||
/**
|
|
||||||
* @typedef {Object} AsciiOptions
|
|
||||||
* @property {number} [width] - Width of the ASCII output in characters.
|
|
||||||
* @property {number} [height] - Height of the ASCII output.
|
|
||||||
* @property {number} [contrast=1.0] - Contrast adjustment (0.0 to 5.0).
|
|
||||||
* @property {number} [exposure=1.0] - Exposure/Brightness adjustment (0.0 to 5.0).
|
|
||||||
* @property {boolean} [invert=false] - Whether to invert the colors.
|
|
||||||
* @property {number} [saturation=1.2] - Saturation adjustment (0.0 to 5.0).
|
|
||||||
* @property {number} [gamma=1.0] - Gamma correction value.
|
|
||||||
* @property {string} [charSet='standard'] - Key of CHAR_SETS or custom string.
|
|
||||||
* @property {boolean} [color=false] - If true, returns HTML with color spans.
|
|
||||||
* @property {boolean} [dither=false] - If true, applies Floyd-Steinberg dithering for smoother gradients.
|
|
||||||
* @property {boolean} [enhanceEdges=false] - If true, applies edge enhancement for line art.
|
|
||||||
* @property {boolean} [autoStretch=true] - If true, stretches histogram to use full character range.
|
|
||||||
* @property {number} [overlayStrength=0.3] - Strength of the overlay blend effect (0.0 to 1.0).
|
|
||||||
* @property {'fit'|'fill'|'stretch'} [aspectMode='fit'] - How to handle aspect ratio.
|
|
||||||
* @property {boolean} [denoise=false] - If true, applies a slight blur to reduce noise.
|
|
||||||
* @property {number} [fontAspectRatio=0.55] - Custom font aspect ratio for height calculation.
|
|
||||||
* @property {function} [onProgress] - Optional callback for progress updates (0-100).
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} AsciiResult
|
|
||||||
* @property {string} output - The ASCII art string (plain text or HTML).
|
|
||||||
* @property {boolean} isHtml - Whether the output contains HTML color spans.
|
|
||||||
* @property {number} width - Width in characters.
|
|
||||||
* @property {number} height - Height in characters.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const CHAR_SETS = {
|
|
||||||
standard: '@W%$NQ08GBR&ODHKUgSMw#Xbdp5q9C26APahk3EFVesm{}o4JZcjnuy[f1xi*7zYt(l/I\\v)T?]r><+^"L;|!~:,-_.\' ',
|
|
||||||
simple: '@%#*+=-:. ',
|
|
||||||
blocks: '█▓▒░ ',
|
|
||||||
minimal: '#+-. ',
|
|
||||||
matrix: 'ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ1234567890:.=*+-<>',
|
|
||||||
dots: '⣿⣷⣯⣟⡿⢿⣻⣽⣾⣶⣦⣤⣄⣀⡀ ',
|
|
||||||
ascii_extended: '░▒▓█▀▄▌▐│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌ '
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Aspect mode options */
|
|
||||||
export const ASPECT_MODES = {
|
|
||||||
fit: 'fit', // Fits within given width/height (default)
|
|
||||||
fill: 'fill', // Fills the area, may crop
|
|
||||||
stretch: 'stretch' // Ignores aspect ratio
|
|
||||||
};
|
|
||||||
|
|
||||||
export class AsciiGenerator {
|
|
||||||
constructor() {
|
|
||||||
this.ctx = null;
|
|
||||||
this.canvas = null;
|
|
||||||
this.sharpCanvas = null;
|
|
||||||
this.sharpCtx = null;
|
|
||||||
this.denoiseCanvas = null;
|
|
||||||
this.denoiseCtx = null;
|
|
||||||
this.colorData = null; // Store color data for color output
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dispose of canvas resources.
|
|
||||||
* Call this when you're done using the generator to free memory.
|
|
||||||
*/
|
|
||||||
dispose() {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts an image to ASCII art.
|
|
||||||
* @param {string|HTMLImageElement} imageSource
|
|
||||||
* @param {AsciiOptions} options
|
|
||||||
* @returns {Promise<string|AsciiResult>} ASCII art string, or AsciiResult if color=true
|
|
||||||
*/
|
|
||||||
async generate(imageSource, options = {}) {
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Configuration
|
|
||||||
const requestedWidth = options.width || 100;
|
|
||||||
const fontAspectRatio = options.fontAspectRatio || 0.55;
|
|
||||||
const imgRatio = this.getImageRatio(img);
|
|
||||||
const aspectMode = options.aspectMode || 'fit';
|
|
||||||
|
|
||||||
// Calculate dimensions based on aspect mode
|
|
||||||
let width, height;
|
|
||||||
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;
|
|
||||||
// For fill, we'll handle cropping in the draw phase
|
|
||||||
} else {
|
|
||||||
// fit (default)
|
|
||||||
width = requestedWidth;
|
|
||||||
height = options.height || Math.floor(requestedWidth / (imgRatio / fontAspectRatio));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve CharSet
|
|
||||||
let charSet = options.charSet || 'standard';
|
|
||||||
if (CHAR_SETS[charSet]) {
|
|
||||||
charSet = CHAR_SETS[charSet];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize Canvas
|
|
||||||
if (!this.canvas) {
|
|
||||||
this.canvas = document.createElement('canvas');
|
|
||||||
}
|
|
||||||
this.canvas.width = width;
|
|
||||||
this.canvas.height = height;
|
|
||||||
this.ctx = this.canvas.getContext('2d');
|
|
||||||
|
|
||||||
// Reuse offscreen canvas for memory efficiency
|
|
||||||
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; // default true
|
|
||||||
const overlayStrength = options.overlayStrength ?? 0.3;
|
|
||||||
const denoise = options.denoise ?? false;
|
|
||||||
const colorOutput = options.color ?? false;
|
|
||||||
|
|
||||||
onProgress(20);
|
|
||||||
|
|
||||||
// Denoise pre-processing (slight blur to reduce noise)
|
|
||||||
let sourceImage = 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');
|
|
||||||
this.denoiseCtx.filter = 'blur(0.5px)';
|
|
||||||
this.denoiseCtx.drawImage(img, 0, 0, width, height);
|
|
||||||
sourceImage = this.denoiseCanvas;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate draw parameters for fill mode (center crop)
|
|
||||||
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) {
|
|
||||||
// Image is wider, crop sides
|
|
||||||
sw = img.height * targetRatio;
|
|
||||||
sx = (img.width - sw) / 2;
|
|
||||||
} else {
|
|
||||||
// Image is taller, crop top/bottom
|
|
||||||
sh = img.width / targetRatio;
|
|
||||||
sy = (img.height - sh) / 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional edge enhancement for line art (Laplacian-like sharpening)
|
|
||||||
if (enhanceEdges) {
|
|
||||||
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');
|
|
||||||
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);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter stacking with overlay blend:
|
|
||||||
* This technique boosts mid-contrast by overlaying the image on itself.
|
|
||||||
* The strength is configurable via overlayStrength option.
|
|
||||||
*/
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Build luminance matrix for processing
|
|
||||||
const lumMatrix = new Float32Array(width * height);
|
|
||||||
let minLum = 1.0, maxLum = 0.0;
|
|
||||||
|
|
||||||
// Store color data if color output is requested
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Store original colors for color output
|
|
||||||
if (colorOutput) {
|
|
||||||
this.colorData[i * 3] = r;
|
|
||||||
this.colorData[i * 3 + 1] = g;
|
|
||||||
this.colorData[i * 3 + 2] = b;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gamma correction
|
|
||||||
if (gamma !== 1.0) {
|
|
||||||
lum = Math.pow(lum, gamma);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invert
|
|
||||||
if (options.invert) {
|
|
||||||
lum = 1 - lum;
|
|
||||||
}
|
|
||||||
|
|
||||||
lumMatrix[i] = lum;
|
|
||||||
if (lum < minLum) minLum = lum;
|
|
||||||
if (lum > maxLum) maxLum = lum;
|
|
||||||
}
|
|
||||||
|
|
||||||
onProgress(60);
|
|
||||||
|
|
||||||
// Histogram auto-stretch: normalize to use full character range
|
|
||||||
const lumRange = maxLum - minLum;
|
|
||||||
if (autoStretch && lumRange > 0.01) {
|
|
||||||
for (let i = 0; i < lumMatrix.length; i++) {
|
|
||||||
lumMatrix[i] = (lumMatrix[i] - minLum) / lumRange;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Floyd-Steinberg dithering (optional)
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Distribute error to neighboring pixels
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Build output string
|
|
||||||
let output = '';
|
|
||||||
|
|
||||||
if (colorOutput) {
|
|
||||||
// HTML color output with spans
|
|
||||||
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];
|
|
||||||
|
|
||||||
// Escape HTML special characters
|
|
||||||
const safeChar = char === '<' ? '<' : char === '>' ? '>' : char === '&' ? '&' : char;
|
|
||||||
output += `<span style="color:rgb(${r},${g},${b})">${safeChar}</span>`;
|
|
||||||
}
|
|
||||||
output += '\n';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Plain text output
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
getImageRatio(img) {
|
|
||||||
if (img.width && img.height) {
|
|
||||||
return img.width / img.height;
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveImage(src) {
|
|
||||||
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'));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backward compatibility wrapper
|
|
||||||
export async function imageToAscii(imageSource, options = {}) {
|
|
||||||
const generator = new AsciiGenerator();
|
|
||||||
return generator.generate(imageSource, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyzes an image and returns suggested options (auto-tune).
|
|
||||||
* @param {HTMLImageElement} img
|
|
||||||
* @returns {AsciiOptions}
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Analyzes an image and returns suggested options (auto-tune).
|
|
||||||
* @param {HTMLImageElement} img
|
|
||||||
* @param {Object} [meta] - Optional metadata from API (dominant color, palette).
|
|
||||||
* @returns {AsciiOptions}
|
|
||||||
*/
|
|
||||||
export function autoTuneImage(img, meta = null) {
|
|
||||||
if (typeof document === 'undefined') return {};
|
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
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 = 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; // Ensure p5 has a value (handles edge case where luminance 0 is the 5th percentile)
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
// improved via Metadata if available
|
|
||||||
if (meta) {
|
|
||||||
const { color_dominant, color_palette } = meta;
|
|
||||||
|
|
||||||
// 1. Invert based on Dominant Color (more reliable than edges for anime art)
|
|
||||||
if (color_dominant) {
|
|
||||||
const [r, g, b] = color_dominant;
|
|
||||||
const domLum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
||||||
// If background/dominant color is bright, we likely need to invert
|
|
||||||
// so that dark lines become characters (white background -> black ink)
|
|
||||||
if (domLum > 140) {
|
|
||||||
invert = true;
|
|
||||||
useEdgeDetection = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Saturation based on Palette vibrancy
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to edge detection if metadata didn't decide inversion
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gamma < 1 lifts shadows (for dark images), gamma = 1 keeps bright images neutral
|
|
||||||
const gamma = avgLum < 80 ? 0.75 : 1.0;
|
|
||||||
|
|
||||||
// === SMART CHARSET RECOMMENDATION ===
|
|
||||||
// Analyze image characteristics to recommend the best charset
|
|
||||||
let recommendedCharSet = 'standard';
|
|
||||||
let denoise = false;
|
|
||||||
let enhanceEdges = false;
|
|
||||||
let overlayStrength = 0.3;
|
|
||||||
|
|
||||||
// Detect image type based on histogram distribution
|
|
||||||
const histogramPeaks = countHistogramPeaks(histogram, pixelCount);
|
|
||||||
const isHighContrast = activeRange > 180;
|
|
||||||
const isLowContrast = activeRange < 80;
|
|
||||||
const isBimodal = histogramPeaks <= 3;
|
|
||||||
|
|
||||||
// Check for line art characteristics (bimodal histogram, few colors)
|
|
||||||
if (isBimodal && activeRange > 150) {
|
|
||||||
recommendedCharSet = 'minimal';
|
|
||||||
enhanceEdges = true;
|
|
||||||
overlayStrength = 0.1; // Less overlay for line art
|
|
||||||
}
|
|
||||||
// High contrast images work well with blocks
|
|
||||||
else if (isHighContrast) {
|
|
||||||
recommendedCharSet = 'blocks';
|
|
||||||
overlayStrength = 0.2;
|
|
||||||
}
|
|
||||||
// Low contrast, possibly noisy images
|
|
||||||
else if (isLowContrast) {
|
|
||||||
recommendedCharSet = 'simple';
|
|
||||||
denoise = true;
|
|
||||||
overlayStrength = 0.5; // More overlay to boost contrast
|
|
||||||
}
|
|
||||||
// Photos with good tonal range
|
|
||||||
else if (activeRange > 100 && activeRange <= 180) {
|
|
||||||
recommendedCharSet = 'standard';
|
|
||||||
// Check for noise by looking at high-frequency variation
|
|
||||||
const noiseLevel = estimateNoiseLevel(pixels, size);
|
|
||||||
if (noiseLevel > 20) {
|
|
||||||
denoise = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use dots charset for images with lots of fine detail
|
|
||||||
if (meta && 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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count significant peaks in histogram for image type detection.
|
|
||||||
* @param {number[]} histogram - 256-bin luminance histogram
|
|
||||||
* @param {number} pixelCount - Total pixel count
|
|
||||||
* @returns {number} Number of significant peaks
|
|
||||||
*/
|
|
||||||
function countHistogramPeaks(histogram, pixelCount) {
|
|
||||||
const threshold = pixelCount * 0.02; // 2% of pixels
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Estimate noise level in image by measuring local variance.
|
|
||||||
* @param {Uint8ClampedArray} pixels - Image pixel data
|
|
||||||
* @param {number} size - Image dimension
|
|
||||||
* @returns {number} Estimated noise level (0-100)
|
|
||||||
*/
|
|
||||||
function estimateNoiseLevel(pixels, size) {
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Get center and neighbor luminances
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
546
src/scripts/ascii.ts
Normal file
546
src/scripts/ascii.ts
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
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 === '<' ? '<' : char === '>' ? '>' : char === '&' ? '&' : 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;
|
||||||
|
}
|
||||||
135
src/scripts/image-loader.ts
Normal file
135
src/scripts/image-loader.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { RateLimiter } from './rate-limiter.js';
|
||||||
|
|
||||||
|
const rateLimiter = new RateLimiter({ minDelay: 1500 });
|
||||||
|
const imageCache = new Map<string, HTMLImageElement>();
|
||||||
|
const failedImages = new Set<string>();
|
||||||
|
|
||||||
|
interface ApiProvider {
|
||||||
|
name: string;
|
||||||
|
base: string;
|
||||||
|
key: string | null;
|
||||||
|
categories: Record<string, string>;
|
||||||
|
requiresKey: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiResponse {
|
||||||
|
url: string;
|
||||||
|
artist: string | undefined;
|
||||||
|
sourceUrl: string | undefined;
|
||||||
|
meta: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FetchOptions {
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_PROVIDERS: ApiProvider[] = [
|
||||||
|
{
|
||||||
|
name: 'waifu.pics',
|
||||||
|
base: 'https://api.waifu.pics/sfw/waifu',
|
||||||
|
key: null,
|
||||||
|
categories: { waifu: 'waifu' },
|
||||||
|
requiresKey: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
let currentProviderIndex = 0;
|
||||||
|
|
||||||
|
async function fetchFromProvider(_category: string, provider: ApiProvider): Promise<ApiResponse> {
|
||||||
|
const apiBase = provider.base;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = { 'Accept': 'application/json' };
|
||||||
|
if (provider.key) {
|
||||||
|
headers['Authorization'] = `ApiKey ${provider.key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(apiBase, { headers });
|
||||||
|
rateLimiter.updateFromHeaders(response.headers);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API Error (${provider.name}): ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as { url?: string };
|
||||||
|
|
||||||
|
if (!data.url) {
|
||||||
|
throw new Error(`Invalid API response format from ${provider.name}: No URL found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageUrl = data.url;
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: `/api-proxy?url=${encodeURIComponent(imageUrl)}`,
|
||||||
|
artist: undefined,
|
||||||
|
sourceUrl: undefined,
|
||||||
|
meta: data
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch from ${provider.name}:`, (error as Error).message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAnimeImage(options: FetchOptions = {}): Promise<ApiResponse> {
|
||||||
|
const { category = 'waifu' } = options;
|
||||||
|
|
||||||
|
await rateLimiter.acquire();
|
||||||
|
|
||||||
|
const numProviders = API_PROVIDERS.length;
|
||||||
|
let lastError: Error | undefined;
|
||||||
|
|
||||||
|
for (let i = 0; i < numProviders; i++) {
|
||||||
|
const providerIndex = (currentProviderIndex + i) % numProviders;
|
||||||
|
const provider = API_PROVIDERS[providerIndex];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchFromProvider(category, provider);
|
||||||
|
|
||||||
|
if (providerIndex !== currentProviderIndex) {
|
||||||
|
currentProviderIndex = providerIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error as Error;
|
||||||
|
console.warn(`Provider ${provider.name} failed, trying next...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Failed to fetch anime image after all providers:', lastError);
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadImage(url: string): Promise<HTMLImageElement> {
|
||||||
|
if (failedImages.has(url)) {
|
||||||
|
throw new Error('Image failed to load previously');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageCache.has(url)) {
|
||||||
|
return imageCache.get(url)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = 'Anonymous';
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
img.onload = () => {
|
||||||
|
imageCache.set(url, img);
|
||||||
|
resolve(img);
|
||||||
|
};
|
||||||
|
img.onerror = (error) => {
|
||||||
|
imageCache.delete(url);
|
||||||
|
failedImages.add(url);
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCache(): void {
|
||||||
|
imageCache.clear();
|
||||||
|
failedImages.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ApiResponse as AnimeImageResult };
|
||||||
73
src/scripts/rate-limiter.ts
Normal file
73
src/scripts/rate-limiter.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
export interface RateLimiterOptions {
|
||||||
|
maxTokens?: number;
|
||||||
|
refillRate?: number;
|
||||||
|
minDelay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RateLimiter {
|
||||||
|
private maxTokens: number;
|
||||||
|
private tokens: number;
|
||||||
|
private refillRate: number;
|
||||||
|
private lastRefill: number;
|
||||||
|
private retryAfter: number;
|
||||||
|
private minDelay: number;
|
||||||
|
|
||||||
|
constructor(options: RateLimiterOptions = {}) {
|
||||||
|
this.maxTokens = options.maxTokens ?? 1;
|
||||||
|
this.tokens = this.maxTokens;
|
||||||
|
this.refillRate = options.refillRate ?? 1;
|
||||||
|
this.lastRefill = Date.now();
|
||||||
|
this.retryAfter = 0;
|
||||||
|
this.minDelay = options.minDelay ?? 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
private refill(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = (now - this.lastRefill) / 1000;
|
||||||
|
this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate);
|
||||||
|
this.lastRefill = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async acquire(): Promise<void> {
|
||||||
|
this.refill();
|
||||||
|
|
||||||
|
if (this.retryAfter > 0) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, this.retryAfter));
|
||||||
|
this.retryAfter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.tokens < 1) {
|
||||||
|
const waitTime = Math.max(this.minDelay, (1 - this.tokens) * 1000 / this.refillRate);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, waitTime));
|
||||||
|
this.refill();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tokens -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateFromHeaders(headers: Headers): void {
|
||||||
|
if (!headers) return;
|
||||||
|
|
||||||
|
const remaining = headers.get('RateLimit-Remaining');
|
||||||
|
const limit = headers.get('RateLimit-Limit');
|
||||||
|
const retryAfterHeader = headers.get('Retry-After');
|
||||||
|
|
||||||
|
if (remaining !== null) {
|
||||||
|
this.tokens = parseFloat(remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (limit !== null) {
|
||||||
|
this.maxTokens = parseFloat(limit);
|
||||||
|
this.refillRate = this.maxTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retryAfterHeader !== null) {
|
||||||
|
this.retryAfter = parseFloat(retryAfterHeader) * 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRemaining(): number {
|
||||||
|
this.refill();
|
||||||
|
return Math.floor(this.tokens);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,63 @@
|
|||||||
|
|
||||||
|
|
||||||
|
export interface RenderOptions {
|
||||||
|
charSetContent: string;
|
||||||
|
fontFamily?: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
exposure: number;
|
||||||
|
contrast: number;
|
||||||
|
saturation: number;
|
||||||
|
gamma: number;
|
||||||
|
invert: boolean;
|
||||||
|
color: boolean;
|
||||||
|
overlayStrength?: number;
|
||||||
|
enhanceEdges?: boolean;
|
||||||
|
dither?: boolean;
|
||||||
|
denoise?: boolean;
|
||||||
|
zoom?: number;
|
||||||
|
zoomCenter?: { x: number; y: number };
|
||||||
|
mousePos?: { x: number; y: number };
|
||||||
|
magnifierRadius?: number;
|
||||||
|
magnifierZoom?: number;
|
||||||
|
showMagnifier?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MagnifierOptions {
|
||||||
|
mousePos?: { x: number; y: number };
|
||||||
|
zoom?: number;
|
||||||
|
zoomCenter?: { x: number; y: number };
|
||||||
|
magnifierRadius?: number;
|
||||||
|
magnifierZoom?: number;
|
||||||
|
showMagnifier?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class WebGLAsciiRenderer {
|
export class WebGLAsciiRenderer {
|
||||||
constructor(canvas) {
|
|
||||||
this.canvas = canvas;
|
private gl: WebGLRenderingContext;
|
||||||
this.gl = canvas.getContext('webgl', { antialias: false });
|
private program: WebGLProgram | null;
|
||||||
if (!this.gl) {
|
private textures: { image?: WebGLTexture; atlas?: WebGLTexture };
|
||||||
|
private buffers: { position?: WebGLBuffer; texCoord?: WebGLBuffer };
|
||||||
|
private charAtlas: { width: number; height: number; charWidth: number; charHeight: number; count: number } | null;
|
||||||
|
private charSet: string;
|
||||||
|
private uniformLocations: Record<string, WebGLUniformLocation | null> = {};
|
||||||
|
|
||||||
|
private fontFamily: string;
|
||||||
|
private lastImage: HTMLImageElement | null;
|
||||||
|
|
||||||
|
constructor(_canvas: HTMLCanvasElement) {
|
||||||
|
const gl = _canvas.getContext('webgl', { antialias: false });
|
||||||
|
if (!gl) {
|
||||||
throw new Error('WebGL not supported');
|
throw new Error('WebGL not supported');
|
||||||
}
|
}
|
||||||
|
this.gl = gl;
|
||||||
|
|
||||||
this.program = null;
|
this.program = null;
|
||||||
this.textures = {};
|
this.textures = {};
|
||||||
this.buffers = {};
|
this.buffers = {};
|
||||||
this.charAtlas = null;
|
this.charAtlas = null;
|
||||||
this.charSet = '';
|
this.charSet = '';
|
||||||
this.fontSize = 12;
|
this.lastImage = null;
|
||||||
this.fontFamily = "'JetBrains Mono', monospace";
|
this.fontFamily = "'JetBrains Mono', monospace";
|
||||||
|
|
||||||
this.init();
|
this.init();
|
||||||
@@ -175,6 +220,7 @@ export class WebGLAsciiRenderer {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
this.program = this.createProgram(vsSource, fsSource);
|
this.program = this.createProgram(vsSource, fsSource);
|
||||||
|
this.cacheUniformLocations();
|
||||||
|
|
||||||
// Grid buffers
|
// Grid buffers
|
||||||
const positions = new Float32Array([
|
const positions = new Float32Array([
|
||||||
@@ -195,11 +241,31 @@ export class WebGLAsciiRenderer {
|
|||||||
gl.bufferData(gl.ARRAY_BUFFER, texCoords, gl.STATIC_DRAW);
|
gl.bufferData(gl.ARRAY_BUFFER, texCoords, gl.STATIC_DRAW);
|
||||||
}
|
}
|
||||||
|
|
||||||
createProgram(vsSource, fsSource) {
|
private cacheUniformLocations(): void {
|
||||||
|
if (!this.program) return;
|
||||||
|
const gl = this.gl;
|
||||||
|
const uniforms = [
|
||||||
|
'u_image', 'u_atlas', 'u_charCount', 'u_gridSize', 'u_texSize',
|
||||||
|
'u_exposure', 'u_contrast', 'u_saturation', 'u_gamma',
|
||||||
|
'u_invert', 'u_color', 'u_overlayStrength', 'u_enhanceEdges',
|
||||||
|
'u_zoom', 'u_zoomCenter', 'u_mousePos',
|
||||||
|
'u_magnifierRadius', 'u_magnifierZoom', 'u_showMagnifier', 'u_aspect'
|
||||||
|
];
|
||||||
|
for (const name of uniforms) {
|
||||||
|
this.uniformLocations[name] = gl.getUniformLocation(this.program, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createProgram(vsSource: string, fsSource: string): WebGLProgram | null {
|
||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
const vs = this.compileShader(gl.VERTEX_SHADER, vsSource);
|
const vs = this.compileShader(gl.VERTEX_SHADER, vsSource);
|
||||||
const fs = this.compileShader(gl.FRAGMENT_SHADER, fsSource);
|
const fs = this.compileShader(gl.FRAGMENT_SHADER, fsSource);
|
||||||
|
|
||||||
|
if (!vs || !fs) return null;
|
||||||
|
|
||||||
const program = gl.createProgram();
|
const program = gl.createProgram();
|
||||||
|
if (!program) return null;
|
||||||
|
|
||||||
gl.attachShader(program, vs);
|
gl.attachShader(program, vs);
|
||||||
gl.attachShader(program, fs);
|
gl.attachShader(program, fs);
|
||||||
gl.linkProgram(program);
|
gl.linkProgram(program);
|
||||||
@@ -210,9 +276,11 @@ export class WebGLAsciiRenderer {
|
|||||||
return program;
|
return program;
|
||||||
}
|
}
|
||||||
|
|
||||||
compileShader(type, source) {
|
compileShader(type: number, source: string): WebGLShader | null {
|
||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
const shader = gl.createShader(type);
|
const shader = gl.createShader(type);
|
||||||
|
if (!shader) return null;
|
||||||
|
|
||||||
gl.shaderSource(shader, source);
|
gl.shaderSource(shader, source);
|
||||||
gl.compileShader(shader);
|
gl.compileShader(shader);
|
||||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||||
@@ -223,7 +291,7 @@ export class WebGLAsciiRenderer {
|
|||||||
return shader;
|
return shader;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAtlas(charSet, fontName = 'monospace') {
|
updateAtlas(charSet: string, fontName = 'monospace') {
|
||||||
if (this.charSet === charSet && this.fontFamily === fontName && this.charAtlas) return;
|
if (this.charSet === charSet && this.fontFamily === fontName && this.charAtlas) return;
|
||||||
|
|
||||||
this.charSet = charSet;
|
this.charSet = charSet;
|
||||||
@@ -231,6 +299,7 @@ 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;
|
||||||
const fontSize = 32; // Higher resolution for atlas
|
const fontSize = 32; // Higher resolution for atlas
|
||||||
ctx.font = `${fontSize}px ${fontName}`;
|
ctx.font = `${fontSize}px ${fontName}`;
|
||||||
|
|
||||||
@@ -254,7 +323,10 @@ export class WebGLAsciiRenderer {
|
|||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
if (this.textures.atlas) gl.deleteTexture(this.textures.atlas);
|
if (this.textures.atlas) gl.deleteTexture(this.textures.atlas);
|
||||||
|
|
||||||
this.textures.atlas = gl.createTexture();
|
const atlasTexture = gl.createTexture();
|
||||||
|
if (!atlasTexture) return;
|
||||||
|
this.textures.atlas = atlasTexture;
|
||||||
|
|
||||||
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);
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||||
@@ -271,16 +343,55 @@ export class WebGLAsciiRenderer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
render(image, options) {
|
updateGrid(width: number, height: number) {
|
||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
const program = this.program;
|
const u = this.uniformLocations;
|
||||||
|
gl.useProgram(this.program);
|
||||||
|
gl.uniform2f(u['u_gridSize'], width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUniforms(options: RenderOptions) {
|
||||||
|
const gl = this.gl;
|
||||||
|
const u = this.uniformLocations;
|
||||||
|
|
||||||
|
gl.useProgram(this.program);
|
||||||
|
|
||||||
|
// Update Atlas if needed (expensive check inside)
|
||||||
this.updateAtlas(options.charSetContent, options.fontFamily || 'monospace');
|
this.updateAtlas(options.charSetContent, options.fontFamily || 'monospace');
|
||||||
|
if (this.charAtlas) {
|
||||||
|
gl.uniform1f(u['u_charCount'], this.charAtlas.count);
|
||||||
|
gl.uniform2f(u['u_texSize'], this.charAtlas.width, this.charAtlas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.uniform1f(u['u_exposure'], options.exposure);
|
||||||
|
gl.uniform1f(u['u_contrast'], options.contrast);
|
||||||
|
gl.uniform1f(u['u_saturation'], options.saturation);
|
||||||
|
gl.uniform1f(u['u_gamma'], options.gamma);
|
||||||
|
gl.uniform1i(u['u_invert'], options.invert ? 1 : 0);
|
||||||
|
gl.uniform1i(u['u_color'], options.color ? 1 : 0);
|
||||||
|
gl.uniform1f(u['u_overlayStrength'], options.overlayStrength || 0.0);
|
||||||
|
gl.uniform1i(u['u_enhanceEdges'], options.enhanceEdges ? 1 : 0);
|
||||||
|
|
||||||
|
// Zoom & Magnifier
|
||||||
|
gl.uniform1f(u['u_zoom'], options.zoom || 1.0);
|
||||||
|
gl.uniform2f(u['u_zoomCenter'], options.zoomCenter?.x ?? 0.5, options.zoomCenter?.y ?? 0.5);
|
||||||
|
gl.uniform2f(u['u_mousePos'], options.mousePos?.x ?? -1.0, options.mousePos?.y ?? -1.0);
|
||||||
|
gl.uniform1f(u['u_magnifierRadius'], options.magnifierRadius || 0.15);
|
||||||
|
gl.uniform1f(u['u_magnifierZoom'], options.magnifierZoom || 2.0);
|
||||||
|
gl.uniform1i(u['u_showMagnifier'], options.showMagnifier ? 1 : 0);
|
||||||
|
gl.uniform1f(u['u_aspect'], gl.canvas.width / gl.canvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTexture(image: HTMLImageElement) {
|
||||||
|
if (this.lastImage === image && this.textures.image) return;
|
||||||
|
|
||||||
|
const gl = this.gl;
|
||||||
|
|
||||||
// Update image texture only if it's a new image
|
|
||||||
if (this.lastImage !== image) {
|
|
||||||
if (this.textures.image) gl.deleteTexture(this.textures.image);
|
if (this.textures.image) gl.deleteTexture(this.textures.image);
|
||||||
this.textures.image = gl.createTexture();
|
const texture = gl.createTexture();
|
||||||
|
if (!texture) throw new Error('Failed to create texture');
|
||||||
|
this.textures.image = texture;
|
||||||
|
|
||||||
gl.bindTexture(gl.TEXTURE_2D, this.textures.image);
|
gl.bindTexture(gl.TEXTURE_2D, this.textures.image);
|
||||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
|
||||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||||
@@ -290,12 +401,18 @@ export class WebGLAsciiRenderer {
|
|||||||
this.lastImage = image;
|
this.lastImage = image;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
const gl = this.gl;
|
||||||
|
const program = this.program;
|
||||||
|
|
||||||
|
if (!program || !this.textures.image || !this.textures.atlas || !this.buffers.position || !this.buffers.texCoord) return;
|
||||||
|
|
||||||
|
gl.useProgram(program);
|
||||||
|
|
||||||
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
|
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
|
||||||
gl.clearColor(0, 0, 0, 0);
|
gl.clearColor(0, 0, 0, 0);
|
||||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
gl.useProgram(program);
|
|
||||||
|
|
||||||
// Attributes
|
// Attributes
|
||||||
const posLoc = gl.getAttribLocation(program, 'a_position');
|
const posLoc = gl.getAttribLocation(program, 'a_position');
|
||||||
gl.enableVertexAttribArray(posLoc);
|
gl.enableVertexAttribArray(posLoc);
|
||||||
@@ -307,37 +424,49 @@ export class WebGLAsciiRenderer {
|
|||||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.texCoord);
|
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.texCoord);
|
||||||
gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0);
|
gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
// Uniforms
|
// Bind Textures
|
||||||
gl.uniform1i(gl.getUniformLocation(program, 'u_image'), 0);
|
const u = this.uniformLocations;
|
||||||
|
gl.uniform1i(u['u_image'], 0);
|
||||||
gl.activeTexture(gl.TEXTURE0);
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
gl.bindTexture(gl.TEXTURE_2D, this.textures.image);
|
gl.bindTexture(gl.TEXTURE_2D, this.textures.image);
|
||||||
|
|
||||||
gl.uniform1i(gl.getUniformLocation(program, 'u_atlas'), 1);
|
gl.uniform1i(u['u_atlas'], 1);
|
||||||
gl.activeTexture(gl.TEXTURE1);
|
gl.activeTexture(gl.TEXTURE1);
|
||||||
gl.bindTexture(gl.TEXTURE_2D, this.textures.atlas);
|
gl.bindTexture(gl.TEXTURE_2D, this.textures.atlas);
|
||||||
|
|
||||||
gl.uniform1f(gl.getUniformLocation(program, 'u_charCount'), this.charAtlas.count);
|
|
||||||
gl.uniform2f(gl.getUniformLocation(program, 'u_gridSize'), options.width, options.height);
|
|
||||||
gl.uniform2f(gl.getUniformLocation(program, 'u_texSize'), this.charAtlas.width, this.charAtlas.height);
|
|
||||||
|
|
||||||
gl.uniform1f(gl.getUniformLocation(program, 'u_exposure'), options.exposure);
|
|
||||||
gl.uniform1f(gl.getUniformLocation(program, 'u_contrast'), options.contrast);
|
|
||||||
gl.uniform1f(gl.getUniformLocation(program, 'u_saturation'), options.saturation);
|
|
||||||
gl.uniform1f(gl.getUniformLocation(program, 'u_gamma'), options.gamma);
|
|
||||||
gl.uniform1i(gl.getUniformLocation(program, 'u_invert'), options.invert ? 1 : 0);
|
|
||||||
gl.uniform1i(gl.getUniformLocation(program, 'u_color'), options.color ? 1 : 0);
|
|
||||||
gl.uniform1f(gl.getUniformLocation(program, 'u_overlayStrength'), options.overlayStrength || 0.0);
|
|
||||||
gl.uniform1i(gl.getUniformLocation(program, 'u_enhanceEdges'), options.enhanceEdges ? 1 : 0);
|
|
||||||
|
|
||||||
// Zoom & Magnifier
|
|
||||||
gl.uniform1f(gl.getUniformLocation(program, 'u_zoom'), options.zoom || 1.0);
|
|
||||||
gl.uniform2f(gl.getUniformLocation(program, 'u_zoomCenter'), options.zoomCenter?.x ?? 0.5, options.zoomCenter?.y ?? 0.5);
|
|
||||||
gl.uniform2f(gl.getUniformLocation(program, 'u_mousePos'), options.mousePos?.x ?? -1.0, options.mousePos?.y ?? -1.0);
|
|
||||||
gl.uniform1f(gl.getUniformLocation(program, 'u_magnifierRadius'), options.magnifierRadius || 0.15);
|
|
||||||
gl.uniform1f(gl.getUniformLocation(program, 'u_magnifierZoom'), options.magnifierZoom || 2.0);
|
|
||||||
gl.uniform1i(gl.getUniformLocation(program, 'u_showMagnifier'), options.showMagnifier ? 1 : 0);
|
|
||||||
gl.uniform1f(gl.getUniformLocation(program, 'u_aspect'), gl.canvas.width / gl.canvas.height);
|
|
||||||
|
|
||||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
render(image: HTMLImageElement, options: RenderOptions) {
|
||||||
|
this.updateTexture(image);
|
||||||
|
this.updateGrid(options.width, options.height);
|
||||||
|
this.updateUniforms(options);
|
||||||
|
this.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kept for backward compatibility or specialized updates
|
||||||
|
updateMagnifier(options: MagnifierOptions) {
|
||||||
|
const gl = this.gl;
|
||||||
|
const program = this.program;
|
||||||
|
|
||||||
|
if (!program) return;
|
||||||
|
|
||||||
|
gl.useProgram(program);
|
||||||
|
|
||||||
|
// Only update magnifier-related uniforms (using cached locations)
|
||||||
|
const u = this.uniformLocations;
|
||||||
|
const mousePos = options.mousePos ?? { x: -1, y: -1 };
|
||||||
|
gl.uniform2f(u['u_mousePos'], mousePos.x, mousePos.y);
|
||||||
|
gl.uniform1f(u['u_magnifierRadius'], options.magnifierRadius || 0.03);
|
||||||
|
gl.uniform1f(u['u_magnifierZoom'], options.magnifierZoom || 2.0);
|
||||||
|
gl.uniform1i(u['u_showMagnifier'], options.showMagnifier ? 1 : 0);
|
||||||
|
|
||||||
|
if (options.zoom !== undefined) {
|
||||||
|
gl.uniform1f(u['u_zoom'], options.zoom || 1.0);
|
||||||
|
gl.uniform2f(u['u_zoomCenter'], options.zoomCenter?.x ?? 0.5, options.zoomCenter?.y ?? 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We can just call draw here as it's lightweight
|
||||||
|
this.draw();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"lib": ["ES2020", "DOM"],
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user