diff --git a/src/pages/index.astro b/src/pages/index.astro index 3c77cec..f7f473f 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -12,6 +12,7 @@ import TuiButton from "../components/TuiButton.astro";
GENERATING...
Preparing art...
+
@@ -203,9 +204,26 @@ import TuiButton from "../components/TuiButton.astro"; autoTuneImage, CHAR_SETS, } from "../scripts/ascii.js"; - import { fetchRandomAnimeImage } from "../scripts/anime-api.js"; + import { + fetchRandomAnimeImage, + fetchMultipleAnimeImages, + } from "../scripts/anime-api.js"; + import { WebGLAsciiRenderer } from "../scripts/webgl-ascii.js"; const generator = new AsciiGenerator(); + const canvas = document.getElementById( + "ascii-canvas", + ) as HTMLCanvasElement; + let webglRenderer: WebGLAsciiRenderer | null = null; + + try { + webglRenderer = new WebGLAsciiRenderer(canvas); + } catch (e) { + console.warn( + "WebGL renderer failed to initialize, falling back to CPU", + e, + ); + } // State let currentImgUrl: string | null = null; @@ -236,7 +254,7 @@ import TuiButton from "../components/TuiButton.astro"; "loading", ) as HTMLDivElement; - if (!asciiResult || !loadingIndicator) { + if (!asciiResult || !loadingIndicator || !canvas) { throw new Error("Critical UI elements missing"); } @@ -365,35 +383,175 @@ import TuiButton from "../components/TuiButton.astro"; heightRows = widthCols / (imgRatio / fontAspectRatio); - try { - const result = await generator.generate(imgEl, { - width: widthCols, - height: Math.floor(heightRows), - ...currentSettings, - }); + // Decide whether to use WebGL or CPU + // We use WebGL primarily for color mode since it's the biggest performance hit + // But we can also use it for everything if currentSettings.color is true + const useWebGL = webglRenderer !== null; - // Handle color output (returns object) vs plain text (returns string) - if (typeof result === "object" && result.isHtml) { - asciiResult.innerHTML = result.output; + if (useWebGL) { + asciiResult.style.display = "none"; + canvas.style.display = "block"; + + // Calculate intended aspect ratio of the ASCII grid + const gridAspect = (widthCols * fontAspectRatio) / heightRows; + const screenW = window.innerWidth; + const screenH = window.innerHeight; + + // Available space with margins (matching CPU version's 0.9 scale) + const maxW = screenW * 0.95; + const maxH = screenH * 0.95; + + let finalW, finalH; + if (gridAspect > maxW / maxH) { + finalW = maxW; + finalH = maxW / gridAspect; } else { - asciiResult.textContent = result as string; + finalH = maxH; + finalW = maxH * gridAspect; } - // Auto-fit font size - const sizeW = (screenW * 0.9) / (widthCols * fontAspectRatio); - const sizeH = (screenH * 0.9) / heightRows; - const bestSize = Math.min(sizeW, sizeH); + // Set canvas CSS size to fit centered and internal resolution for sharpness + canvas.style.width = `${finalW}px`; + canvas.style.height = `${finalH}px`; - asciiResult.style.fontSize = `${Math.max(4, bestSize).toFixed(2)}px`; - asciiResult.style.opacity = "1"; - } catch (e) { - console.error("Render error", e); + const dpr = window.devicePixelRatio || 1; + canvas.width = finalW * dpr; + canvas.height = finalH * dpr; + + const charSetContent = + CHAR_SETS[currentSettings.charSet] || CHAR_SETS.standard; + + webglRenderer!.render(imgEl, { + width: widthCols, + height: Math.floor(heightRows), + charSetContent: charSetContent, + ...currentSettings, + zoom: zoom, + zoomCenter: zoomCenter, + mousePos: mousePos, + showMagnifier: showMagnifier, + magnifierRadius: 0.03, + magnifierZoom: 2.5, + }); + + canvas.style.opacity = "1"; + } else { + canvas.style.display = "none"; + asciiResult.style.display = "block"; + + try { + const result = await generator.generate(imgEl, { + width: widthCols, + height: Math.floor(heightRows), + ...currentSettings, + }); + + // Handle color output (returns object) vs plain text (returns string) + if (typeof result === "object" && result.isHtml) { + asciiResult.innerHTML = result.output; + } else { + asciiResult.textContent = result as string; + } + + // Auto-fit font size + const sizeW = + (screenW * 0.9) / (widthCols * fontAspectRatio); + const sizeH = (screenH * 0.9) / heightRows; + const bestSize = Math.min(sizeW, sizeH); + + asciiResult.style.fontSize = `${Math.max(4, bestSize).toFixed(2)}px`; + asciiResult.style.opacity = "1"; + } catch (e) { + console.error("Render error", e); + } } } + // Zoom & Magnifier State + let zoom = 1.0; + let zoomCenter = { x: 0.5, y: 0.5 }; + let mousePos = { x: -1, y: -1 }; + let showMagnifier = false; + + const heroWrapper = document.querySelector(".hero-wrapper"); + + if (heroWrapper) { + heroWrapper.addEventListener( + "wheel", + (e: any) => { + // If over controls, don't zoom + if (e.target.closest("#tui-controls")) return; + + // Only zoom if using WebGL (as CPU version doesn't support it yet) + if (webglRenderer) { + e.preventDefault(); + + const delta = -e.deltaY; + const factor = delta > 0 ? 1.1 : 0.9; + const oldZoom = zoom; + + zoom *= factor; + + // Cap zoom + zoom = Math.min(Math.max(zoom, 1.0), 10.0); + + if (zoom === 1.0) { + zoomCenter = { x: 0.5, y: 0.5 }; + } else if (oldZoom !== zoom) { + // Calculate where the mouse is relative to the canvas + const rect = canvas.getBoundingClientRect(); + const mx = (e.clientX - rect.left) / rect.width; + const my = (e.clientY - rect.top) / rect.height; + + // To zoom into the mouse, we want the image coordinate under the mouse to stay fixed. + // Shader formula: uv = (v_texCoord - C) / Z + C + // We want: (mx - C1) / Z1 + C1 == (mx - C2) / Z2 + C2 + + const imgX = + (mx - zoomCenter.x) / oldZoom + zoomCenter.x; + const imgY = + (my - zoomCenter.y) / oldZoom + zoomCenter.y; + + // Solve for C2: K = (mx - C2) / Z2 + C2 => C2 = (K - mx/Z2) / (1 - 1/Z2) + zoomCenter.x = (imgX - mx / zoom) / (1 - 1 / zoom); + zoomCenter.y = (imgY - my / zoom) / (1 - 1 / zoom); + } + + generate(); + } + }, + { passive: false }, + ); + + heroWrapper.addEventListener("mousemove", (e: any) => { + if (webglRenderer) { + const rect = canvas.getBoundingClientRect(); + const mx = (e.clientX - rect.left) / rect.width; + const my = (e.clientY - rect.top) / rect.height; + + mousePos = { x: mx, y: my }; + + // Show magnifier if mouse is over canvas + const wasShowing = showMagnifier; + showMagnifier = mx >= 0 && mx <= 1 && my >= 0 && my <= 1; + + if (showMagnifier || wasShowing) { + generate(); // Re-render to update magnifier position + } + } + }); + + heroWrapper.addEventListener("mouseleave", () => { + if (showMagnifier) { + showMagnifier = false; + generate(); + } + }); + } + // Queue System const imageQueue: { data: any; imgElement: HTMLImageElement }[] = []; - const TARGET_QUEUE_SIZE = 3; + const TARGET_QUEUE_SIZE = 5; let isFetchingQueue = false; async function fillQueue() { @@ -405,18 +563,31 @@ import TuiButton from "../components/TuiButton.astro"; isFetchingQueue = true; try { - // Fetch one by one until full - while (imageQueue.length < TARGET_QUEUE_SIZE) { - const data = await fetchRandomAnimeImage(); - // Preload - const img = await resolveImage(data.url); - // Store - imageQueue.push({ - data: data, - imgElement: img, - }); - // Brief pause to be nice to API - await new Promise((r) => setTimeout(r, 500)); + const needed = TARGET_QUEUE_SIZE - imageQueue.length; + if (needed <= 0) return; + + // Fetch multiple metadata entries at once (API allows this) + const results = await fetchMultipleAnimeImages({ + amount: needed, + }); + + // Load images SEQUENTIALLY with a small delay to avoid 429s from the CDN + for (const data of results) { + if (imageQueue.length >= TARGET_QUEUE_SIZE) break; + try { + const img = await resolveImage(data.url); + imageQueue.push({ + data: data, + imgElement: img, + }); + updateQueueStatus(); + // Small staggered delay + await new Promise((r) => setTimeout(r, 300)); + } catch (e) { + console.error("Failed to preload image", data.url, e); + // If we hit an error (likely rate limit or CORS), wait longer + await new Promise((r) => setTimeout(r, 2000)); + } } } catch (e) { console.error("Queue fill error", e); @@ -455,6 +626,10 @@ import TuiButton from "../components/TuiButton.astro"; fillQueue(); } + // Reset zoom on new image + zoom = 1.0; + zoomCenter = { x: 0.5, y: 0.5 }; + // Reset auto mode and apply auto-detected settings invertMode = "auto"; detectedInvert = suggestions.invert; @@ -698,6 +873,16 @@ import TuiButton from "../components/TuiButton.astro"; transform-origin: center; } + #ascii-canvas { + width: 100%; + height: 100%; + object-fit: contain; + display: none; + image-rendering: pixelated; + opacity: 0; + transition: opacity 0.5s ease; + } + #loading { position: absolute; top: 50%; diff --git a/src/scripts/webgl-ascii.js b/src/scripts/webgl-ascii.js new file mode 100644 index 0000000..8db5c46 --- /dev/null +++ b/src/scripts/webgl-ascii.js @@ -0,0 +1,343 @@ + +export class WebGLAsciiRenderer { + constructor(canvas) { + this.canvas = canvas; + this.gl = canvas.getContext('webgl', { antialias: false }); + if (!this.gl) { + throw new Error('WebGL not supported'); + } + + this.program = null; + this.textures = {}; + this.buffers = {}; + this.charAtlas = null; + this.charSet = ''; + this.fontSize = 12; + this.fontFamily = "'JetBrains Mono', monospace"; + + this.init(); + } + + init() { + const gl = this.gl; + + // Vertex Shader + const vsSource = ` + attribute vec2 a_position; + attribute vec2 a_texCoord; + varying vec2 v_texCoord; + void main() { + gl_Position = vec4(a_position, 0.0, 1.0); + v_texCoord = a_texCoord; + } + `; + + // Fragment Shader + const fsSource = ` + precision mediump float; + varying vec2 v_texCoord; + + uniform sampler2D u_image; + uniform sampler2D u_atlas; + uniform float u_charCount; + uniform vec2 u_gridSize; // cols, rows + uniform vec2 u_texSize; // atlas size + + // Adjustments + uniform float u_exposure; + uniform float u_contrast; + uniform float u_saturation; + uniform float u_gamma; + uniform bool u_invert; + uniform bool u_color; + uniform float u_overlayStrength; + uniform bool u_enhanceEdges; + + // Zoom & Magnifier + uniform float u_zoom; + uniform vec2 u_zoomCenter; + uniform vec2 u_mousePos; + uniform float u_magnifierRadius; + uniform float u_magnifierZoom; + uniform bool u_showMagnifier; + uniform float u_aspect; + + vec3 adjust(vec3 color) { + // Exposure + color *= u_exposure; + + // Contrast + color = (color - 0.5) * u_contrast + 0.5; + + // Saturation + float luma = dot(color, vec3(0.2126, 0.7152, 0.0722)); + color = mix(vec3(luma), color, u_saturation); + + // Gamma + color = pow(max(color, 0.0), vec3(u_gamma)); + + return clamp(color, 0.0, 1.0); + } + + void main() { + vec2 uv = v_texCoord; + + // Apply global zoom + uv = (uv - u_zoomCenter) / u_zoom + u_zoomCenter; + + // Magnifier logic + vec2 diff = (v_texCoord - u_mousePos); + diff.x *= u_aspect; + float dist = length(diff); + bool inMagnifier = u_showMagnifier && dist < u_magnifierRadius; + + if (inMagnifier) { + // Zoom towards mouse position inside the magnifier + uv = (v_texCoord - u_mousePos) / u_magnifierZoom + u_mousePos; + // Also account for the global zoom background + uv = (uv - u_zoomCenter) / u_zoom + u_zoomCenter; + } + + // Calculate which cell we are in + vec2 cellCoords = floor(uv * u_gridSize); + vec2 uvInCell = fract(uv * u_gridSize); + + // Sample image at the center of the cell + vec2 sampleUV = (cellCoords + 0.5) / u_gridSize; + + // Out of bounds check for zoomed UV + if (sampleUV.x < 0.0 || sampleUV.x > 1.0 || sampleUV.y < 0.0 || sampleUV.y > 1.0) { + discard; + } + + vec3 color = texture2D(u_image, sampleUV).rgb; + + // Edge Enhancement (Simple Laplacian-like check) + if (u_enhanceEdges) { + vec2 texel = 1.0 / u_gridSize; + vec3 center = texture2D(u_image, sampleUV).rgb; + vec3 top = texture2D(u_image, sampleUV + vec2(0.0, -texel.y)).rgb; + vec3 bottom = texture2D(u_image, sampleUV + vec2(0.0, texel.y)).rgb; + vec3 left = texture2D(u_image, sampleUV + vec2(-texel.x, 0.0)).rgb; + vec3 right = texture2D(u_image, sampleUV + vec2(texel.x, 0.0)).rgb; + + vec3 edges = abs(center - top) + abs(center - bottom) + abs(center - left) + abs(center - right); + float edgeLum = dot(edges, vec3(0.2126, 0.7152, 0.0722)); + color = mix(color, color * (1.0 - edgeLum * 2.0), 0.5); + } + + // Apply adjustments + color = adjust(color); + + // Overlay blend-like effect (boost mid-contrast) + if (u_overlayStrength > 0.0) { + vec3 overlay = color; + vec3 result; + if (dot(color, vec3(0.333)) < 0.5) { + result = 2.0 * color * overlay; + } else { + result = 1.0 - 2.0 * (1.0 - color) * (1.0 - overlay); + } + color = mix(color, result, u_overlayStrength); + } + + // Calculate luminance + float luma = dot(color, vec3(0.2126, 0.7152, 0.0722)); + + if (u_invert) { + luma = 1.0 - luma; + } + + // Map luma to character index + float charIndex = floor(luma * (u_charCount - 1.0) + 0.5); + + // Sample character atlas + vec2 atlasUV = vec2( + (charIndex + uvInCell.x) / u_charCount, + uvInCell.y + ); + + float charAlpha = texture2D(u_atlas, atlasUV).r; + + // Loup border effect + if (u_showMagnifier) { + float edgeWidth = 0.005; + if (dist > u_magnifierRadius - edgeWidth && dist < u_magnifierRadius) { + charAlpha = 1.0; + color = vec3(1.0, 0.4039, 0.0); // Safety Orange border for the loupe + } + } + + vec3 finalColor = u_color ? color : vec3(1.0, 0.4039, 0.0); + + gl_FragColor = vec4(finalColor * charAlpha, charAlpha); + } + `; + + this.program = this.createProgram(vsSource, fsSource); + + // Grid buffers + const positions = new Float32Array([ + -1, -1, 1, -1, -1, 1, + -1, 1, 1, -1, 1, 1 + ]); + const texCoords = new Float32Array([ + 0, 1, 1, 1, 0, 0, + 0, 0, 1, 1, 1, 0 + ]); + + this.buffers.position = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.position); + gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW); + + this.buffers.texCoord = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.texCoord); + gl.bufferData(gl.ARRAY_BUFFER, texCoords, gl.STATIC_DRAW); + } + + createProgram(vsSource, fsSource) { + const gl = this.gl; + const vs = this.compileShader(gl.VERTEX_SHADER, vsSource); + const fs = this.compileShader(gl.FRAGMENT_SHADER, fsSource); + const program = gl.createProgram(); + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + console.error(gl.getProgramInfoLog(program)); + return null; + } + return program; + } + + compileShader(type, source) { + const gl = this.gl; + const shader = gl.createShader(type); + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + console.error(gl.getShaderInfoLog(shader)); + gl.deleteShader(shader); + return null; + } + return shader; + } + + updateAtlas(charSet, fontName = 'monospace') { + if (this.charSet === charSet && this.fontFamily === fontName && this.charAtlas) return; + + this.charSet = charSet; + this.fontFamily = fontName; + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const fontSize = 32; // Higher resolution for atlas + ctx.font = `${fontSize}px ${fontName}`; + + // Measure first char to get dimensions + const metrics = ctx.measureText('W'); + const charWidth = Math.ceil(metrics.width); + const charHeight = fontSize * 1.2; + + canvas.width = charWidth * charSet.length; + canvas.height = charHeight; + + ctx.font = `${fontSize}px ${fontName}`; + ctx.fillStyle = 'white'; + ctx.textBaseline = 'top'; + ctx.clearRect(0, 0, canvas.width, canvas.height); + + for (let i = 0; i < charSet.length; i++) { + ctx.fillText(charSet[i], i * charWidth, 0); + } + + const gl = this.gl; + if (this.textures.atlas) gl.deleteTexture(this.textures.atlas); + + this.textures.atlas = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, this.textures.atlas); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + this.charAtlas = { + width: canvas.width, + height: canvas.height, + charWidth, + charHeight, + count: charSet.length + }; + } + + render(image, options) { + const gl = this.gl; + const program = this.program; + + this.updateAtlas(options.charSetContent, options.fontFamily || 'monospace'); + + // Update image texture only if it's a new image + if (this.lastImage !== image) { + if (this.textures.image) gl.deleteTexture(this.textures.image); + this.textures.image = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, this.textures.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_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + this.lastImage = image; + } + + gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + + gl.useProgram(program); + + // Attributes + const posLoc = gl.getAttribLocation(program, 'a_position'); + gl.enableVertexAttribArray(posLoc); + gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.position); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0); + + const texLoc = gl.getAttribLocation(program, 'a_texCoord'); + gl.enableVertexAttribArray(texLoc); + gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.texCoord); + gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0); + + // Uniforms + gl.uniform1i(gl.getUniformLocation(program, 'u_image'), 0); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.textures.image); + + gl.uniform1i(gl.getUniformLocation(program, 'u_atlas'), 1); + gl.activeTexture(gl.TEXTURE1); + 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); + } +}