diff --git a/src/pages/index.astro b/src/pages/index.astro index b51ab72..3eace07 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -206,6 +206,34 @@ import Tooltip from "../components/Tooltip.astro"; + + +
+ + +
+
EXPORT
+
+ + + +
+
diff --git a/src/scripts/ascii-controller.ts b/src/scripts/ascii-controller.ts index 1767932..b948d21 100644 --- a/src/scripts/ascii-controller.ts +++ b/src/scripts/ascii-controller.ts @@ -3,25 +3,13 @@ * Manages state, render loop, and grid calculations. */ -import { CHAR_SETS, type CharSetKey, type AsciiOptions } from './ascii-shared'; +import { CHAR_SETS, type CharSetKey, type AsciiOptions, type AsciiSettings } from './ascii-shared'; import { WebGLAsciiRenderer, type RenderOptions } from './webgl-ascii'; +import { AsciiExporter } from './ascii-exporter'; // ============= Types ============= -export interface AsciiSettings { - exposure: number; - contrast: number; - saturation: number; - gamma: number; - invert: boolean; - color: boolean; - dither: number; - denoise: boolean; - edgeMode: number; - overlayStrength: number; - resolution: number; - charSet: CharSetKey; -} + export interface GridCache { widthCols: number; @@ -426,6 +414,35 @@ export class AsciiController { } } + // ============= Export ============= + + savePNG(): void { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + AsciiExporter.downloadPNG(this.canvas, `ascii-art-${timestamp}.png`); + } + + async copyText(): Promise { + if (!this.cachedGrid.imgEl) return; + const text = AsciiExporter.generateText( + this.cachedGrid.imgEl, + this.settings, + this.cachedGrid.widthCols, + Math.floor(this.cachedGrid.heightRows) + ); + await AsciiExporter.copyToClipboard(text); + } + + async copyHTML(): Promise { + if (!this.cachedGrid.imgEl) return; + const html = AsciiExporter.generateHTML( + this.cachedGrid.imgEl, + this.settings, + this.cachedGrid.widthCols, + Math.floor(this.cachedGrid.heightRows) + ); + await AsciiExporter.copyToClipboard(html); + } + // ============= Utilities ============= private resolveImage(src: string): Promise { diff --git a/src/scripts/ascii-exporter.ts b/src/scripts/ascii-exporter.ts new file mode 100644 index 0000000..f3a66de --- /dev/null +++ b/src/scripts/ascii-exporter.ts @@ -0,0 +1,171 @@ +import { CHAR_SETS, type AsciiSettings } from './ascii-shared'; + +export class AsciiExporter { + static downloadPNG(canvas: HTMLCanvasElement, filename = 'ascii-art.png') { + const link = document.createElement('a'); + link.download = filename; + link.href = canvas.toDataURL('image/png'); + link.click(); + } + + static async copyToClipboard(text: string): Promise { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + } else { + // Fallback + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.position = "fixed"; + textArea.style.left = "-9999px"; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + } + } + + static generateText( + img: HTMLImageElement, + settings: AsciiSettings, + widthCols: number, + heightRows: number + ): string { + const { pixels } = this.getPixels(img, widthCols, heightRows); + let output = ""; + const charSet = CHAR_SETS[settings.charSet] || CHAR_SETS.standard; + const charCount = charSet.length; + + for (let y = 0; y < heightRows; y++) { + for (let x = 0; x < widthCols; x++) { + const i = (y * widthCols + x) * 4; + const r = pixels[i]; + const g = pixels[i + 1]; + const b = pixels[i + 2]; + // const a = pixels[i + 3]; // Ignore alpha for now + + // 1. Adjust Color (Exposure, Contrast, Saturation, Gamma) + const adjColor = this.adjustColor(r, g, b, settings); + + // 2. Calculate Luma from adjusted color + let luma = (0.2126 * adjColor.r + 0.7152 * adjColor.g + 0.0722 * adjColor.b) / 255; + + // 3. Map to Char + if (settings.invert) luma = 1.0 - luma; + + const charIndex = Math.floor(luma * (charCount - 1) + 0.5); + const char = charSet[Math.max(0, Math.min(charCount - 1, charIndex))]; + + output += char; + } + output += "\n"; + } + + return output; + } + + static generateHTML( + img: HTMLImageElement, + settings: AsciiSettings, + widthCols: number, + heightRows: number + ): string { + const { pixels } = this.getPixels(img, widthCols, heightRows); + let output = `
`;
+        const charSet = CHAR_SETS[settings.charSet] || CHAR_SETS.standard;
+        const charCount = charSet.length;
+
+        for (let y = 0; y < heightRows; y++) {
+            for (let x = 0; x < widthCols; x++) {
+                const i = (y * widthCols + x) * 4;
+                const r = pixels[i];
+                const g = pixels[i + 1];
+                const b = pixels[i + 2];
+
+                // 1. Calculate Luma
+                let luma = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
+
+                // 2. Apply Adjustments
+                // Note: For color mode, we might want to keep original color 
+                // but apply luma to alpha or just use luma for char selection.
+                // The shader uses `color` argument. 
+
+                // Adjust color for display
+                const adjColor = this.adjustColor(r, g, b, settings);
+
+                // Recalculate luma from adjusted color for char selection
+                let finalLuma = (0.2126 * adjColor.r + 0.7152 * adjColor.g + 0.0722 * adjColor.b) / 255;
+
+                // Adjust luma curve for char selection (gamma/contrast/exposure) explicitly?
+                // Actually `adjustColor` does that.
+
+                if (settings.invert) finalLuma = 1.0 - finalLuma;
+
+                const charIndex = Math.floor(finalLuma * (charCount - 1) + 0.5);
+                const char = charSet[Math.max(0, Math.min(charCount - 1, charIndex))];
+
+                // Escape HTML
+                const safeChar = char === '<' ? '<' : char === '>' ? '>' : char === '&' ? '&' : char;
+
+                const colorStyle = `color: rgb(${Math.round(adjColor.r)}, ${Math.round(adjColor.g)}, ${Math.round(adjColor.b)})`;
+                output += `${safeChar}`;
+            }
+            output += "
"; + } + output += "
"; + return output; + } + + private static getPixels(img: HTMLImageElement, width: number, height: number) { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error("Canvas 2D context not available"); + + // High quality downscaling + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(img, 0, 0, width, height); + + return { + pixels: ctx.getImageData(0, 0, width, height).data, + ctx + }; + } + + + + private static adjustColor(r: number, g: number, b: number, settings: AsciiSettings): { r: number, g: number, b: number } { + let rNorm = r / 255; + let gNorm = g / 255; + let bNorm = b / 255; + + // Exposure + rNorm *= settings.exposure; + gNorm *= settings.exposure; + bNorm *= settings.exposure; + + // Contrast + rNorm = (rNorm - 0.5) * settings.contrast + 0.5; + gNorm = (gNorm - 0.5) * settings.contrast + 0.5; + bNorm = (bNorm - 0.5) * settings.contrast + 0.5; + + // Saturation + const luma = 0.2126 * rNorm + 0.7152 * gNorm + 0.0722 * bNorm; + rNorm = luma + (rNorm - luma) * settings.saturation; + gNorm = luma + (gNorm - luma) * settings.saturation; + bNorm = luma + (bNorm - luma) * settings.saturation; + + // Gamma + rNorm = Math.pow(Math.max(0, rNorm), settings.gamma); + gNorm = Math.pow(Math.max(0, gNorm), settings.gamma); + bNorm = Math.pow(Math.max(0, bNorm), settings.gamma); + + return { + r: Math.max(0, Math.min(255, rNorm * 255)), + g: Math.max(0, Math.min(255, gNorm * 255)), + b: Math.max(0, Math.min(255, bNorm * 255)) + }; + } +} diff --git a/src/scripts/ascii-shared.ts b/src/scripts/ascii-shared.ts index e9ca76a..866aecf 100644 --- a/src/scripts/ascii-shared.ts +++ b/src/scripts/ascii-shared.ts @@ -42,6 +42,21 @@ export interface ImageMetadata { has_fine_detail?: boolean; } +export interface AsciiSettings { + exposure: number; + contrast: number; + saturation: number; + gamma: number; + invert: boolean; + color: boolean; + dither: number; + denoise: boolean; + edgeMode: number; + overlayStrength: number; + resolution: number; + charSet: CharSetKey; +} + // ============= Constants ============= export const CHAR_SETS: Record = { diff --git a/src/scripts/ui-bindings.ts b/src/scripts/ui-bindings.ts index 804d7e6..6956c5b 100644 --- a/src/scripts/ui-bindings.ts +++ b/src/scripts/ui-bindings.ts @@ -3,8 +3,8 @@ * Event listeners, keyboard shortcuts, and UI synchronization. */ -import { CHARSET_SHORT_MAP, CHARSET_REVERSE_MAP } from './ascii-shared'; -import type { AsciiController, AsciiSettings } from './ascii-controller'; +import { CHARSET_SHORT_MAP, CHARSET_REVERSE_MAP, type AsciiSettings } from './ascii-shared'; +import type { AsciiController } from './ascii-controller'; import type { ImageQueue } from './image-queue'; // ============= Window Extensions ============= @@ -136,9 +136,19 @@ export class UIBindings { // Cleanup Resize if (this.resizeHandler) { + window.removeEventListener('resize', this.resizeHandler); window.removeEventListener('resize', this.resizeHandler); this.resizeHandler = null; } + + // Cleanup Export Buttons + ['btn-save-png', 'btn-copy-text', 'btn-copy-html'].forEach(id => { + const el = document.getElementById(id); + const handler = this.buttonHandlers.get(id); + if (el && handler) { + el.removeEventListener('click', handler); + } + }); } // ============= Sliders ============= @@ -284,6 +294,37 @@ export class UIBindings { this.buttonHandlers.set('btn-next', handler); btnNext.addEventListener('click', handler); } + + // Export Buttons + const btnSavePng = document.getElementById('btn-save-png'); + if (btnSavePng) { + const handler = (e: Event) => { + e.stopPropagation(); + this.controller.savePNG(); + }; + this.buttonHandlers.set('btn-save-png', handler); + btnSavePng.addEventListener('click', handler); + } + + const btnCopyText = document.getElementById('btn-copy-text'); + if (btnCopyText) { + const handler = async (e: Event) => { + e.stopPropagation(); + await this.controller.copyText(); + }; + this.buttonHandlers.set('btn-copy-text', handler); + btnCopyText.addEventListener('click', handler); + } + + const btnCopyHtml = document.getElementById('btn-copy-html'); + if (btnCopyHtml) { + const handler = async (e: Event) => { + e.stopPropagation(); + await this.controller.copyHTML(); + }; + this.buttonHandlers.set('btn-copy-html', handler); + btnCopyHtml.addEventListener('click', handler); + } } // ============= Keyboard =============