feat(export): add export options (PNG, text, HTML)
This commit is contained in:
@@ -206,6 +206,34 @@ import Tooltip from "../components/Tooltip.astro";
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="control-panel-divider"></div>
|
||||
|
||||
<!-- Export Section -->
|
||||
<div class="control-panel-section export-section">
|
||||
<div class="section-header">EXPORT</div>
|
||||
<div class="actions-row">
|
||||
<TuiButton
|
||||
id="btn-save-png"
|
||||
label="PNG"
|
||||
title="Save as Image"
|
||||
description="Download high-res PNG capture of the current view."
|
||||
/>
|
||||
<TuiButton
|
||||
id="btn-copy-text"
|
||||
label="TXT"
|
||||
title="Copy Text"
|
||||
description="Copy raw ASCII text to clipboard."
|
||||
/>
|
||||
<TuiButton
|
||||
id="btn-copy-html"
|
||||
label="HTML"
|
||||
title="Copy HTML"
|
||||
description="Copy colored HTML span elements to clipboard."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Keyboard shortcuts hint -->
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<HTMLImageElement> {
|
||||
|
||||
171
src/scripts/ascii-exporter.ts
Normal file
171
src/scripts/ascii-exporter.ts
Normal file
@@ -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<void> {
|
||||
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 = `<pre style="font-family: monospace; line-height: 1; letter-spacing: 0; background-color: #000; color: #fff; font-size: 8px;">`;
|
||||
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 += `<span style="${colorStyle}">${safeChar}</span>`;
|
||||
}
|
||||
output += "<br>";
|
||||
}
|
||||
output += "</pre>";
|
||||
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))
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<CharSetKey, string> = {
|
||||
|
||||
@@ -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 =============
|
||||
|
||||
Reference in New Issue
Block a user