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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Keyboard shortcuts hint -->
|
<!-- Keyboard shortcuts hint -->
|
||||||
|
|||||||
@@ -3,25 +3,13 @@
|
|||||||
* Manages state, render loop, and grid calculations.
|
* 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 { WebGLAsciiRenderer, type RenderOptions } from './webgl-ascii';
|
||||||
|
import { AsciiExporter } from './ascii-exporter';
|
||||||
|
|
||||||
// ============= Types =============
|
// ============= 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 {
|
export interface GridCache {
|
||||||
widthCols: number;
|
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 =============
|
// ============= Utilities =============
|
||||||
|
|
||||||
private resolveImage(src: string): Promise<HTMLImageElement> {
|
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;
|
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 =============
|
// ============= Constants =============
|
||||||
|
|
||||||
export const CHAR_SETS: Record<CharSetKey, string> = {
|
export const CHAR_SETS: Record<CharSetKey, string> = {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
* Event listeners, keyboard shortcuts, and UI synchronization.
|
* Event listeners, keyboard shortcuts, and UI synchronization.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { CHARSET_SHORT_MAP, CHARSET_REVERSE_MAP } from './ascii-shared';
|
import { CHARSET_SHORT_MAP, CHARSET_REVERSE_MAP, type AsciiSettings } from './ascii-shared';
|
||||||
import type { AsciiController, AsciiSettings } from './ascii-controller';
|
import type { AsciiController } from './ascii-controller';
|
||||||
import type { ImageQueue } from './image-queue';
|
import type { ImageQueue } from './image-queue';
|
||||||
|
|
||||||
// ============= Window Extensions =============
|
// ============= Window Extensions =============
|
||||||
@@ -136,9 +136,19 @@ export class UIBindings {
|
|||||||
|
|
||||||
// Cleanup Resize
|
// Cleanup Resize
|
||||||
if (this.resizeHandler) {
|
if (this.resizeHandler) {
|
||||||
|
window.removeEventListener('resize', this.resizeHandler);
|
||||||
window.removeEventListener('resize', this.resizeHandler);
|
window.removeEventListener('resize', this.resizeHandler);
|
||||||
this.resizeHandler = null;
|
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 =============
|
// ============= Sliders =============
|
||||||
@@ -284,6 +294,37 @@ export class UIBindings {
|
|||||||
this.buttonHandlers.set('btn-next', handler);
|
this.buttonHandlers.set('btn-next', handler);
|
||||||
btnNext.addEventListener('click', 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 =============
|
// ============= Keyboard =============
|
||||||
|
|||||||
Reference in New Issue
Block a user