├── index.html ├── og.webp ├── script.js ├── style.css └── yeag.webp /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | yeaag - yet another ascii art generator 7 | 11 | 15 | 16 | 20 | 24 | 25 | 26 | 27 | 28 | 29 | 33 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 |
46 |
47 |
48 |

yeaag

49 | yeaag 50 |
51 |

yet another ascii art generator

52 |
53 | 54 |
55 |
56 |
57 | 63 |
64 | 72 | 75 | 76 | 77 | 78 |

Drop your image here or click to upload

79 |
80 |
81 | 82 |
83 |
84 | 99 | 100 | 116 |
117 | 118 |
119 | 127 | 128 | 132 | 133 | 137 |
138 | 139 |
140 | 144 | 145 | 146 |
147 | 163 | 179 |
180 |
181 | 182 |
183 | 192 |
193 | 194 |
195 | 208 | 209 | 227 | 228 |
229 | 237 | 245 |
246 |
247 | 248 |
249 | 252 | 253 |
254 |
255 |
256 | 257 |
258 | 259 |
260 |
261 | 262 | 268 |
269 | 270 | 271 | 272 | -------------------------------------------------------------------------------- /og.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seatedro/yeaag/2c52f0b65d8041df83cf0614bc9d323e949a5675/og.webp -------------------------------------------------------------------------------- /script.js: -------------------------------------------------------------------------------- 1 | const DITHER_ALGORITHMS = { 2 | "floyd-steinberg": (error, x, y, w) => [ 3 | [7 / 16, x + 1, y], 4 | [3 / 16, x - 1, y + 1], 5 | [5 / 16, x, y + 1], 6 | [1 / 16, x + 1, y + 1], 7 | ], 8 | atkinson: (error, x, y, w) => [ 9 | [1 / 8, x + 1, y], 10 | [1 / 8, x + 2, y], 11 | [1 / 8, x - 1, y + 1], 12 | [1 / 8, x, y + 1], 13 | [1 / 8, x + 1, y + 1], 14 | [1 / 8, x, y + 2], 15 | ], 16 | "bayer-2x2": [ 17 | [0, 2], 18 | [3, 1], 19 | ], 20 | "bayer-4x4": [ 21 | [0, 8, 2, 10], 22 | [12, 4, 14, 6], 23 | [3, 11, 1, 9], 24 | [15, 7, 13, 5], 25 | ], 26 | ordered: [ 27 | [0, 48, 12, 60, 3, 51, 15, 63], 28 | [32, 16, 44, 28, 35, 19, 47, 31], 29 | [8, 56, 4, 52, 11, 59, 7, 55], 30 | [40, 24, 36, 20, 43, 27, 39, 23], 31 | [2, 50, 14, 62, 1, 49, 13, 61], 32 | [34, 18, 46, 30, 33, 17, 45, 29], 33 | [10, 58, 6, 54, 9, 57, 5, 53], 34 | [42, 26, 38, 22, 41, 25, 37, 21], 35 | ], 36 | }; 37 | 38 | const PALETTES = { 39 | "b&w": { fg: "#ffffff", bg: "#000000" }, 40 | terminal: { fg: "#00ff00", bg: "#000000" }, 41 | amber: { fg: "#ffb000", bg: "#000000" }, 42 | "low-contrast": { 43 | fg: "#d36a6f", 44 | bg: "#15091b", 45 | }, 46 | nord: { 47 | bg: "#2e3440", 48 | palette: ["#3b4252", "#434c5e", "#4c566a", "#d8dee9", "#e5e9f0"], 49 | }, 50 | catppuccin: { 51 | bg: "#1e1e2e", 52 | palette: ["#cdd6f4", "#f5e0dc", "#f2cdcd", "#89b4fa"], 53 | }, 54 | }; 55 | 56 | class AsciiArtGenerator { 57 | constructor() { 58 | this.canvas = document.getElementById("outputCanvas"); 59 | this.ctx = this.canvas.getContext("2d", { willReadFrequently: true }); 60 | this.dragArea = document.getElementById("dragArea"); 61 | this.fileInput = document.getElementById("imageInput"); 62 | 63 | this.blockSize = 8; 64 | this.brightness = 1.0; 65 | this.autoAdjust = true; 66 | this.detectEdges = false; 67 | this.color = true; 68 | this.invertColor = false; 69 | this.asciiChars = " .:-=+*#%@"; 70 | this.sigma1 = 0.5; 71 | this.sigma2 = 1.0; 72 | this.ditherAlgo = "none"; 73 | this.palette = "original"; 74 | this.fgColor = "#ffffff"; 75 | this.bgColor = "#000000"; 76 | 77 | // error diffusion 78 | this.errorBuffer = null; 79 | 80 | this.setupEventListeners(); 81 | } 82 | 83 | setupEventListeners() { 84 | this.dragArea.addEventListener("click", () => this.fileInput.click()); 85 | this.dragArea.addEventListener("dragover", (e) => { 86 | e.preventDefault(); 87 | this.dragArea.style.borderColor = "var(--accent)"; 88 | }); 89 | this.dragArea.addEventListener("dragleave", () => { 90 | this.dragArea.style.borderColor = "var(--border-color)"; 91 | }); 92 | this.dragArea.addEventListener("drop", (e) => { 93 | e.preventDefault(); 94 | this.dragArea.style.borderColor = "var(--border-color)"; 95 | const file = e.dataTransfer.files[0]; 96 | if (file && file.type.startsWith("image/")) { 97 | this.handleFile(file); 98 | } 99 | }); 100 | 101 | this.fileInput.addEventListener("change", (e) => { 102 | const file = e.target.files[0]; 103 | if (file) this.handleFile(file); 104 | }); 105 | 106 | document.getElementById("blockSize").addEventListener("input", (e) => { 107 | this.blockSize = parseInt(e.target.value); 108 | document.getElementById("blockSizeValue").textContent = this.blockSize; 109 | if (this.originalImage) this.generate(); 110 | }); 111 | 112 | document.getElementById("brightness").addEventListener("input", (e) => { 113 | this.brightness = parseFloat(e.target.value); 114 | document.getElementById("brightnessValue").textContent = 115 | this.brightness.toFixed(1); 116 | if (this.originalImage) this.generate(); 117 | }); 118 | 119 | document.getElementById("autoAdjust").addEventListener("change", (e) => { 120 | this.autoAdjust = e.target.checked; 121 | if (this.originalImage) this.generate(); 122 | }); 123 | 124 | const sigmaControls = document.getElementById("sigmaControls"); 125 | 126 | document.getElementById("detectEdges").addEventListener("change", (e) => { 127 | this.detectEdges = e.target.checked; 128 | sigmaControls.classList.toggle("active", e.target.checked); 129 | if (this.originalImage) this.generate(); 130 | }); 131 | 132 | document.getElementById("sigma1").addEventListener("input", (e) => { 133 | this.sigma1 = parseFloat(e.target.value); 134 | document.getElementById("sigma1Value").textContent = 135 | this.sigma1.toFixed(1); 136 | if (this.originalImage) this.generate(); 137 | }); 138 | 139 | document.getElementById("sigma2").addEventListener("input", (e) => { 140 | this.sigma2 = parseFloat(e.target.value); 141 | document.getElementById("sigma2Value").textContent = 142 | this.sigma2.toFixed(1); 143 | if (this.originalImage) this.generate(); 144 | }); 145 | 146 | document.getElementById("color").addEventListener("change", (e) => { 147 | this.color = e.target.checked; 148 | if (this.originalImage) this.generate(); 149 | }); 150 | 151 | document.getElementById("invertColor").addEventListener("change", (e) => { 152 | this.invertColor = e.target.checked; 153 | if (this.originalImage) this.generate(); 154 | }); 155 | 156 | document.getElementById("asciiChars").addEventListener("input", (e) => { 157 | this.asciiChars = e.target.value; 158 | if (this.originalImage) this.generate(); 159 | }); 160 | 161 | document 162 | .getElementById("downloadBtn") 163 | .addEventListener("click", () => this.downloadImage()); 164 | 165 | document.getElementById("ditherAlgo").addEventListener("change", (e) => { 166 | this.ditherAlgo = e.target.value; 167 | if (this.originalImage) this.generate(); 168 | }); 169 | 170 | document.getElementById("colorPalette").addEventListener("change", (e) => { 171 | this.palette = e.target.value; 172 | if (this.palette !== "custom") { 173 | const colors = PALETTES[this.palette]; 174 | if (colors.fg && colors.bg) { 175 | this.fgColor = colors.fg; 176 | this.bgColor = colors.bg; 177 | document.getElementById("fgColor").value = colors.fg; 178 | document.getElementById("bgColor").value = colors.bg; 179 | } 180 | } 181 | if (this.originalImage) this.generate(); 182 | }); 183 | 184 | document.getElementById("fgColor").addEventListener("input", (e) => { 185 | this.fgColor = e.target.value; 186 | if (this.originalImage) this.generate(); 187 | }); 188 | 189 | document.getElementById("bgColor").addEventListener("input", (e) => { 190 | this.bgColor = e.target.value; 191 | if (this.originalImage) this.generate(); 192 | }); 193 | 194 | document 195 | .getElementById("copyBtn") 196 | .addEventListener("click", () => this.copyImage()); 197 | } 198 | 199 | async handleFile(file) { 200 | const img = new Image(); 201 | img.src = URL.createObjectURL(file); 202 | await new Promise((resolve) => (img.onload = resolve)); 203 | this.originalImage = img; 204 | this.generate(); 205 | } 206 | 207 | async handleImageUpload(event) { 208 | const file = event.target.files[0]; 209 | if (file) { 210 | const img = new Image(); 211 | img.src = URL.createObjectURL(file); 212 | await new Promise((resolve) => (img.onload = resolve)); 213 | this.originalImage = img; 214 | this.generate(); 215 | } 216 | } 217 | 218 | rgbToGrayscale(imageData) { 219 | const gray = new Uint8Array(imageData.width * imageData.height); 220 | for (let i = 0; i < imageData.data.length; i += 4) { 221 | const r = imageData.data[i]; 222 | const g = imageData.data[i + 1]; 223 | const b = imageData.data[i + 2]; 224 | gray[i / 4] = Math.round(0.299 * r + 0.587 * g + 0.114 * b); 225 | } 226 | return gray; 227 | } 228 | 229 | gaussianKernel(sigma) { 230 | const size = Math.ceil(6 * sigma); 231 | const kernel = new Float32Array(size); 232 | const half = size / 2; 233 | let sum = 0; 234 | 235 | for (let i = 0; i < size; i++) { 236 | const x = i - half; 237 | kernel[i] = Math.exp(-(x * x) / (2 * sigma * sigma)); 238 | sum += kernel[i]; 239 | } 240 | 241 | for (let i = 0; i < size; i++) { 242 | kernel[i] /= sum; 243 | } 244 | 245 | return kernel; 246 | } 247 | 248 | applyGaussianBlur(imageData, sigma) { 249 | const width = imageData.width; 250 | const height = imageData.height; 251 | const kernel = this.gaussianKernel(sigma); 252 | const temp = new Uint8Array(width * height); 253 | const result = new Uint8Array(width * height); 254 | const gray = this.rgbToGrayscale(imageData); 255 | 256 | for (let y = 0; y < height; y++) { 257 | for (let x = 0; x < width; x++) { 258 | let sum = 0; 259 | for (let i = 0; i < kernel.length; i++) { 260 | const ix = x + i - Math.floor(kernel.length / 2); 261 | if (ix >= 0 && ix < width) { 262 | sum += gray[y * width + ix] * kernel[i]; 263 | } 264 | } 265 | temp[y * width + x] = sum; 266 | } 267 | } 268 | 269 | for (let y = 0; y < height; y++) { 270 | for (let x = 0; x < width; x++) { 271 | let sum = 0; 272 | for (let i = 0; i < kernel.length; i++) { 273 | const iy = y + i - Math.floor(kernel.length / 2); 274 | if (iy >= 0 && iy < height) { 275 | sum += temp[iy * width + x] * kernel[i]; 276 | } 277 | } 278 | result[y * width + x] = sum; 279 | } 280 | } 281 | 282 | return result; 283 | } 284 | 285 | differenceOfGaussians(imageData) { 286 | const blur1 = this.applyGaussianBlur(imageData, this.sigma1); 287 | const blur2 = this.applyGaussianBlur(imageData, this.sigma2); 288 | const result = new Uint8Array(imageData.width * imageData.height); 289 | 290 | for (let i = 0; i < result.length; i++) { 291 | const diff = blur1[i] - blur2[i]; 292 | result[i] = Math.max(0, Math.min(255, diff + 128)); 293 | } 294 | 295 | return result; 296 | } 297 | 298 | applySobelFilter(grayData, width, height) { 299 | const Gx = [ 300 | [-1, 0, 1], 301 | [-2, 0, 2], 302 | [-1, 0, 1], 303 | ]; 304 | const Gy = [ 305 | [-1, -2, -1], 306 | [0, 0, 0], 307 | [1, 2, 1], 308 | ]; 309 | const magnitude = new Float32Array(width * height); 310 | const direction = new Float32Array(width * height); 311 | 312 | for (let y = 1; y < height - 1; y++) { 313 | for (let x = 1; x < width - 1; x++) { 314 | let gx = 0, 315 | gy = 0; 316 | 317 | for (let i = 0; i < 3; i++) { 318 | for (let j = 0; j < 3; j++) { 319 | const pixel = grayData[(y + i - 1) * width + (x + j - 1)]; 320 | gx += Gx[i][j] * pixel; 321 | gy += Gy[i][j] * pixel; 322 | } 323 | } 324 | 325 | magnitude[y * width + x] = Math.sqrt(gx * gx + gy * gy); 326 | direction[y * width + x] = Math.atan2(gy, gx); 327 | } 328 | } 329 | 330 | return { magnitude, direction }; 331 | } 332 | 333 | getEdgeChar(magnitude, direction) { 334 | const threshold = 50; 335 | if (magnitude < threshold && !this.thresholdDisabled) { 336 | return null; 337 | } 338 | 339 | const angle = (direction + Math.PI) * (180 / Math.PI); 340 | const index = Math.floor(((angle + 22.5) % 180) / 45); 341 | 342 | return ["-", "\\", "|", "/", "-", "\\", "|", "/"][index]; 343 | } 344 | 345 | autoBrightnessContrast(imageData) { 346 | const gray = this.rgbToGrayscale(imageData); 347 | const histogram = new Array(256).fill(0); 348 | 349 | for (let i = 0; i < gray.length; i++) { 350 | histogram[gray[i]]++; 351 | } 352 | 353 | const accumulator = new Array(256); 354 | accumulator[0] = histogram[0]; 355 | for (let i = 1; i < 256; i++) { 356 | accumulator[i] = accumulator[i - 1] + histogram[i]; 357 | } 358 | 359 | const max = accumulator[255]; 360 | const clipPercent = 1; 361 | const clipHistCount = Math.floor((max * clipPercent) / 100 / 2); 362 | 363 | let minGray = 0; 364 | while (accumulator[minGray] < clipHistCount) minGray++; 365 | 366 | let maxGray = 255; 367 | while (accumulator[maxGray] >= max - clipHistCount) maxGray--; 368 | 369 | const alpha = 255 / (maxGray - minGray); 370 | const beta = -minGray * alpha; 371 | 372 | const result = new Uint8ClampedArray(imageData.data.length); 373 | for (let i = 0; i < imageData.data.length; i++) { 374 | result[i] = imageData.data[i] * alpha + beta; 375 | } 376 | 377 | return new ImageData(result, imageData.width, imageData.height); 378 | } 379 | 380 | calculateBlockInfo(imageData, x, y, edgeData = null) { 381 | const width = imageData.width; 382 | const height = imageData.height; 383 | const blockW = Math.min(this.blockSize, width - x); 384 | const blockH = Math.min(this.blockSize, height - y); 385 | 386 | let sumBrightness = 0; 387 | let sumColor = [0, 0, 0]; 388 | let pixelCount = 0; 389 | let sumMag = 0; 390 | let sumDir = 0; 391 | 392 | for (let dy = 0; dy < blockH; dy++) { 393 | for (let dx = 0; dx < blockW; dx++) { 394 | const ix = x + dx; 395 | const iy = y + dy; 396 | const i = (iy * width + ix) * 4; 397 | 398 | if (ix >= width || iy >= height || i + 2 >= imageData.data.length) 399 | continue; 400 | 401 | const r = imageData.data[i]; 402 | const g = imageData.data[i + 1]; 403 | const b = imageData.data[i + 2]; 404 | const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b); 405 | 406 | sumBrightness += gray; 407 | if (this.color) { 408 | sumColor[0] += r; 409 | sumColor[1] += g; 410 | sumColor[2] += b; 411 | } 412 | 413 | if (this.detectEdges && edgeData) { 414 | const edgeIndex = iy * width + ix; 415 | sumMag += edgeData.magnitude[edgeIndex]; 416 | sumDir += edgeData.direction[edgeIndex]; 417 | } 418 | 419 | pixelCount++; 420 | } 421 | } 422 | 423 | return { 424 | sumBrightness, 425 | sumColor, 426 | pixelCount, 427 | sumMag, 428 | sumDir, 429 | }; 430 | } 431 | 432 | selectAsciiChar(blockInfo) { 433 | const avgBrightness = Math.floor( 434 | blockInfo.sumBrightness / blockInfo.pixelCount, 435 | ); 436 | const boostedBrightness = Math.floor(avgBrightness * this.brightness); 437 | const clampedBrightness = Math.max(0, Math.min(255, boostedBrightness)); 438 | 439 | if (this.detectEdges) { 440 | const avgMag = blockInfo.sumMag / blockInfo.pixelCount; 441 | const avgDir = blockInfo.sumDir / blockInfo.pixelCount; 442 | const edgeChar = this.getEdgeChar(avgMag, avgDir); 443 | if (edgeChar) return edgeChar; 444 | } 445 | 446 | if (clampedBrightness === 0) return " "; 447 | const charIndex = Math.floor( 448 | (clampedBrightness * this.asciiChars.length) / 256, 449 | ); 450 | return this.asciiChars[Math.min(charIndex, this.asciiChars.length - 1)]; 451 | } 452 | 453 | calculateAverageColor(blockInfo) { 454 | if (!this.color) return [255, 255, 255]; 455 | 456 | const color = blockInfo.sumColor.map((sum) => 457 | Math.floor(sum / blockInfo.pixelCount), 458 | ); 459 | if (this.invertColor) { 460 | return color.map((c) => 255 - c); 461 | } 462 | return color; 463 | } 464 | 465 | async generate() { 466 | if (!this.originalImage) return; 467 | 468 | const scaleFactor = 4; 469 | const width = this.originalImage.width; 470 | const height = this.originalImage.height; 471 | this.canvas.width = width; 472 | this.canvas.height = height; 473 | 474 | this.ctx.drawImage(this.originalImage, 0, 0); 475 | let imageData = this.ctx.getImageData(0, 0, width, height); 476 | 477 | if (this.autoAdjust) { 478 | imageData = this.autoBrightnessContrast(imageData); 479 | } 480 | 481 | imageData = this.applyDithering(imageData, width, height); 482 | 483 | imageData = this.applyColorPalette(imageData); 484 | 485 | let edgeData = null; 486 | if (this.detectEdges) { 487 | const dogResult = this.differenceOfGaussians(imageData); 488 | edgeData = this.applySobelFilter(dogResult, width, height); 489 | } 490 | 491 | const outCanvas = document.createElement("canvas"); 492 | outCanvas.width = width * scaleFactor; 493 | outCanvas.height = height * scaleFactor; 494 | const outCtx = outCanvas.getContext("2d"); 495 | outCtx.scale(scaleFactor, scaleFactor); 496 | const outImageData = outCtx.createImageData(width, height); 497 | 498 | for (let y = 0; y < height; y += this.blockSize) { 499 | for (let x = 0; x < width; x += this.blockSize) { 500 | const blockInfo = this.calculateBlockInfo(imageData, x, y, edgeData); 501 | const char = this.selectAsciiChar(blockInfo); 502 | const color = this.calculateAverageColor(blockInfo); 503 | 504 | outCtx.fillStyle = `rgb(${color[0]}, ${color[1]}, ${color[2]})`; 505 | outCtx.font = `${this.blockSize}px monospace`; 506 | outCtx.fillText(char, x, y + this.blockSize); 507 | } 508 | } 509 | 510 | this.ctx.clearRect(0, 0, width, height); 511 | this.ctx.fillStyle = this.invertColor ? "#ffffff" : "#000000"; 512 | this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); 513 | this.ctx.imageSmoothingEnabled = true; 514 | this.ctx.drawImage( 515 | outCanvas, 516 | 0, 517 | 0, 518 | outCanvas.width, 519 | outCanvas.height, 520 | 0, 521 | 0, 522 | this.canvas.width, 523 | this.canvas.height, 524 | ); 525 | } 526 | 527 | downloadImage() { 528 | if (!this.canvas.toDataURL) return; 529 | 530 | const link = document.createElement("a"); 531 | link.download = "ascii-art.png"; 532 | link.href = this.canvas.toDataURL("image/png"); 533 | link.click(); 534 | } 535 | 536 | async copyImage() { 537 | try { 538 | const blob = await new Promise((resolve) => this.canvas.toBlob(resolve)); 539 | await navigator.clipboard.write([ 540 | new ClipboardItem({ "image/png": blob }), 541 | ]); 542 | } catch (err) { 543 | console.error("Failed to copy:", err); 544 | } 545 | } 546 | 547 | applyColorPalette(imageData) { 548 | const data = new Uint8ClampedArray(imageData.data); 549 | 550 | if (this.palette === "original") { 551 | return imageData; 552 | } 553 | 554 | const paletteConfig = PALETTES[this.palette]; 555 | 556 | if (paletteConfig.fg && paletteConfig.bg) { 557 | for (let i = 0; i < data.length; i += 4) { 558 | const brightness = (data[i] + data[i + 1] + data[i + 2]) / 3; 559 | const color = brightness > 127 ? paletteConfig.fg : paletteConfig.bg; 560 | const rgb = this.hexToRgb(color); 561 | data[i] = rgb.r; 562 | data[i + 1] = rgb.g; 563 | data[i + 2] = rgb.b; 564 | } 565 | } else if (paletteConfig.palette) { 566 | const bg = paletteConfig.bg 567 | ? this.hexToRgb(paletteConfig.bg) 568 | : { r: 0, g: 0, b: 0 }; 569 | for (let i = 0; i < data.length; i += 4) { 570 | const brightness = (data[i] + data[i + 1] + data[i + 2]) / 3; 571 | if (brightness < 20) { 572 | data[i] = bg.r; 573 | data[i + 1] = bg.g; 574 | data[i + 2] = bg.b; 575 | } else { 576 | const paletteIndex = Math.floor( 577 | (brightness / 256) * paletteConfig.palette.length, 578 | ); 579 | const color = this.hexToRgb(paletteConfig.palette[paletteIndex]); 580 | data[i] = color.r; 581 | data[i + 1] = color.g; 582 | data[i + 2] = color.b; 583 | } 584 | } 585 | } 586 | 587 | return new ImageData(data, imageData.width, imageData.height); 588 | } 589 | 590 | hexToRgb(hex) { 591 | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 592 | return result 593 | ? { 594 | r: parseInt(result[1], 16), 595 | g: parseInt(result[2], 16), 596 | b: parseInt(result[3], 16), 597 | } 598 | : { r: 0, g: 0, b: 0 }; 599 | } 600 | 601 | applyDithering(imageData, width, height) { 602 | if (this.ditherAlgo === "none") return imageData; 603 | 604 | const data = new Uint8ClampedArray(imageData.data); 605 | this.errorBuffer = new Float32Array(width * height * 4); 606 | 607 | if (this.ditherAlgo.startsWith("bayer")) { 608 | return this.applyBayerDithering(data, width, height); 609 | } else if (this.ditherAlgo === "ordered") { 610 | return this.applyOrderedDithering(data, width, height); 611 | } 612 | 613 | const matrix = DITHER_ALGORITHMS[this.ditherAlgo]; 614 | 615 | for (let y = 0; y < height; y++) { 616 | for (let x = 0; x < width; x++) { 617 | const i = (y * width + x) * 4; 618 | 619 | for (let c = 0; c < 3; c++) { 620 | const oldPixel = data[i + c] + this.errorBuffer[i + c]; 621 | const newPixel = oldPixel > 127 ? 255 : 0; 622 | const error = oldPixel - newPixel; 623 | 624 | data[i + c] = newPixel; 625 | 626 | matrix(error, x, y, width).forEach(([factor, dx, dy]) => { 627 | if (dx >= 0 && dx < width && dy >= 0 && dy < height) { 628 | const di = (dy * width + dx) * 4; 629 | this.errorBuffer[di + c] += error * factor; 630 | } 631 | }); 632 | } 633 | } 634 | } 635 | 636 | return new ImageData(data, width, height); 637 | } 638 | 639 | applyBayerDithering(data, width, height) { 640 | const matrix = DITHER_ALGORITHMS[this.ditherAlgo]; 641 | const matrixSize = matrix.length; 642 | const divisor = matrixSize * matrixSize + 1; 643 | 644 | for (let y = 0; y < height; y++) { 645 | for (let x = 0; x < width; x++) { 646 | const i = (y * width + x) * 4; 647 | const threshold = matrix[y % matrixSize][x % matrixSize] / divisor; 648 | 649 | for (let c = 0; c < 3; c++) { 650 | const oldPixel = data[i + c] / 255; 651 | data[i + c] = oldPixel > threshold ? 255 : 0; 652 | } 653 | } 654 | } 655 | 656 | return new ImageData(data, width, height); 657 | } 658 | 659 | applyOrderedDithering(data, width, height) { 660 | const matrix = DITHER_ALGORITHMS.ordered; 661 | const matrixSize = 8; 662 | const levels = 64; 663 | 664 | for (let y = 0; y < height; y++) { 665 | for (let x = 0; x < width; x++) { 666 | const i = (y * width + x) * 4; 667 | const threshold = matrix[y % matrixSize][x % matrixSize] / levels; 668 | 669 | for (let c = 0; c < 3; c++) { 670 | const oldPixel = data[i + c] / 255; 671 | data[i + c] = oldPixel > threshold ? 255 : 0; 672 | } 673 | } 674 | } 675 | 676 | return new ImageData(data, width, height); 677 | } 678 | } 679 | 680 | document.addEventListener("DOMContentLoaded", () => { 681 | window.asciiGenerator = new AsciiArtGenerator(); 682 | }); 683 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg-color: #0f0f0f; 3 | --card-bg: #1a1a1a; 4 | --text-primary: #ffffff; 5 | --text-secondary: #a0a0a0; 6 | --accent: #646cff; 7 | --accent-hover: #747bff; 8 | --border-color: #2a2a2a; 9 | --input-bg: #2a2a2a; 10 | } 11 | 12 | * { 13 | margin: 0; 14 | padding: 0; 15 | box-sizing: border-box; 16 | text-transform: lowercase; 17 | } 18 | 19 | body { 20 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, 21 | sans-serif; 22 | line-height: 1.5; 23 | color: var(--text-primary); 24 | background: var(--bg-color); 25 | min-height: 100vh; 26 | } 27 | 28 | .app { 29 | max-width: 1400px; 30 | margin: 0 auto; 31 | padding: 2rem; 32 | display: flex; 33 | flex-direction: column; 34 | min-height: 100vh; 35 | gap: 2rem; 36 | } 37 | 38 | header { 39 | text-align: center; 40 | } 41 | 42 | h1 { 43 | font-size: 3rem; 44 | font-weight: 800; 45 | background: linear-gradient(90deg, var(--accent), var(--accent-hover)); 46 | -webkit-background-clip: text; 47 | -webkit-text-fill-color: transparent; 48 | margin-bottom: 0.5rem; 49 | } 50 | 51 | .title { 52 | text-align: center; 53 | justify-content: center; 54 | align-items: center; 55 | display: flex; 56 | gap: 1rem; 57 | } 58 | 59 | .subtitle { 60 | color: var(--text-secondary); 61 | font-size: 1.1rem; 62 | } 63 | 64 | main { 65 | display: grid; 66 | grid-template-columns: 300px 1fr; 67 | gap: 2rem; 68 | flex: 1; 69 | } 70 | 71 | .card { 72 | background: var(--card-bg); 73 | border-radius: 12px; 74 | border: 1px solid var(--border-color); 75 | overflow: hidden; 76 | } 77 | 78 | .controls-card { 79 | padding: 1.5rem; 80 | display: flex; 81 | flex-direction: column; 82 | gap: 1.5rem; 83 | } 84 | 85 | .drag-area { 86 | border: 2px dashed var(--border-color); 87 | border-radius: 8px; 88 | padding: 2rem 1rem; 89 | text-align: center; 90 | cursor: pointer; 91 | transition: all 0.3s ease; 92 | } 93 | 94 | .drag-area:hover { 95 | border-color: var(--accent); 96 | } 97 | 98 | .drag-content { 99 | display: flex; 100 | flex-direction: column; 101 | align-items: center; 102 | gap: 1rem; 103 | color: var(--text-secondary); 104 | } 105 | 106 | .drag-content svg { 107 | stroke: var(--text-secondary); 108 | } 109 | 110 | .control-groups { 111 | display: flex; 112 | flex-direction: column; 113 | gap: 2rem; 114 | } 115 | 116 | .control-group { 117 | display: flex; 118 | flex-direction: column; 119 | gap: 1rem; 120 | border: 1px dotted var(--border-color); 121 | padding: 1rem; 122 | border-radius: 6px; 123 | } 124 | 125 | label { 126 | display: flex; 127 | flex-direction: column; 128 | gap: 0.5rem; 129 | } 130 | 131 | .slider-container { 132 | display: flex; 133 | align-items: center; 134 | gap: 1rem; 135 | } 136 | 137 | input[type="range"] { 138 | flex: 1; 139 | height: 4px; 140 | -webkit-appearance: none; 141 | background: var(--input-bg); 142 | border-radius: 2px; 143 | outline: none; 144 | } 145 | 146 | input[type="range"]::-webkit-slider-thumb { 147 | -webkit-appearance: none; 148 | width: 16px; 149 | height: 16px; 150 | border-radius: 50%; 151 | background: var(--accent); 152 | cursor: pointer; 153 | transition: all 0.2s ease; 154 | } 155 | 156 | input[type="range"]::-webkit-slider-thumb:hover { 157 | background: var(--accent-hover); 158 | } 159 | 160 | .value { 161 | min-width: 3ch; 162 | color: var(--text-secondary); 163 | } 164 | 165 | .text-input { 166 | background: var(--input-bg); 167 | border: 1px solid var(--border-color); 168 | color: var(--text-primary); 169 | padding: 0.5rem; 170 | border-radius: 6px; 171 | font-family: monospace; 172 | font-size: 1rem; 173 | } 174 | 175 | .text-input:focus { 176 | border-color: var(--accent); 177 | outline: none; 178 | } 179 | 180 | .toggles { 181 | display: flex; 182 | gap: 1rem; 183 | } 184 | 185 | .toggle { 186 | flex-direction: row; 187 | align-items: center; 188 | cursor: pointer; 189 | } 190 | 191 | .toggle input { 192 | display: none; 193 | } 194 | 195 | .toggle-label { 196 | position: relative; 197 | padding-left: 50px; 198 | user-select: none; 199 | } 200 | 201 | .toggle-label:before { 202 | content: ""; 203 | position: absolute; 204 | left: 0; 205 | width: 40px; 206 | height: 22px; 207 | background: var(--input-bg); 208 | border-radius: 11px; 209 | transition: all 0.3s ease; 210 | } 211 | 212 | .toggle-label:after { 213 | content: ""; 214 | position: absolute; 215 | left: 2px; 216 | top: 2px; 217 | width: 18px; 218 | height: 18px; 219 | background: var(--text-secondary); 220 | border-radius: 50%; 221 | transition: all 0.3s ease; 222 | } 223 | 224 | .toggle input:checked + .toggle-label:before { 225 | background: var(--accent); 226 | } 227 | 228 | .toggle input:checked + .toggle-label:after { 229 | left: 20px; 230 | background: white; 231 | } 232 | 233 | .buttons { 234 | display: flex; 235 | gap: 1rem; 236 | } 237 | 238 | button { 239 | flex: 1; 240 | background: var(--input-bg); 241 | color: var(--text-primary); 242 | border: 1px solid var(--border-color); 243 | padding: 0.75rem 1.5rem; 244 | border-radius: 6px; 245 | cursor: pointer; 246 | font-size: 1rem; 247 | transition: all 0.2s ease; 248 | } 249 | 250 | button:hover { 251 | background: #333; 252 | } 253 | 254 | button.primary { 255 | background: var(--accent); 256 | border: none; 257 | } 258 | 259 | button.primary:hover { 260 | background: var(--accent-hover); 261 | } 262 | 263 | .output-card { 264 | display: flex; 265 | align-items: center; 266 | justify-content: center; 267 | padding: 1rem; 268 | } 269 | 270 | #outputCanvas { 271 | max-width: 100%; 272 | max-height: 80vh; 273 | } 274 | 275 | .select-input { 276 | background: var(--input-bg); 277 | border: 1px solid var(--border-color); 278 | color: var(--text-primary); 279 | padding: 0.5rem; 280 | border-radius: 6px; 281 | font-size: 1rem; 282 | width: 100%; 283 | outline: none; 284 | } 285 | 286 | .select-input:focus { 287 | border-color: var(--accent); 288 | } 289 | 290 | .color-pickers { 291 | display: grid; 292 | grid-template-columns: 1fr 1fr; 293 | gap: 1rem; 294 | } 295 | 296 | .color-pickers label { 297 | display: flex; 298 | flex-direction: column; 299 | gap: 0.5rem; 300 | } 301 | 302 | input[type="color"] { 303 | -webkit-appearance: none; 304 | width: 100%; 305 | height: 40px; 306 | padding: 0; 307 | border: 1px solid var(--border-color); 308 | border-radius: 6px; 309 | background: var(--input-bg); 310 | cursor: pointer; 311 | } 312 | 313 | input[type="color"]::-webkit-color-swatch-wrapper { 314 | padding: 2px; 315 | } 316 | 317 | input[type="color"]::-webkit-color-swatch { 318 | border: none; 319 | border-radius: 4px; 320 | } 321 | 322 | footer { 323 | text-align: center; 324 | color: var(--text-secondary); 325 | font-size: 0.9rem; 326 | } 327 | 328 | .sigma-controls { 329 | display: flex; 330 | flex-direction: column; 331 | gap: 1rem; 332 | margin-top: 1rem; 333 | display: none; /* hidden by default */ 334 | } 335 | 336 | .sigma-controls.active { 337 | display: flex; 338 | } 339 | 340 | @media (max-width: 768px) { 341 | main { 342 | grid-template-columns: 1fr; 343 | } 344 | 345 | .app { 346 | padding: 1rem; 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /yeag.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seatedro/yeaag/2c52f0b65d8041df83cf0614bc9d323e949a5675/yeag.webp --------------------------------------------------------------------------------