mirror of
https://github.com/YuzuZensai/TrollSSH.git
synced 2026-09-13 18:19:03 +00:00
✨ feat: add charset presets, invert option, and cached ASCII rendering
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { parentPort, workerData } from "worker_threads";
|
||||
import fs from "fs";
|
||||
|
||||
function loadPacked(filename: string): {
|
||||
fps: number;
|
||||
lengths: Uint32Array;
|
||||
packed: Uint8Array;
|
||||
} {
|
||||
const parsed = JSON.parse(fs.readFileSync(filename, "utf8"));
|
||||
|
||||
if (
|
||||
!parsed ||
|
||||
!Array.isArray(parsed.frames) ||
|
||||
parsed.frames.length === 0 ||
|
||||
typeof parsed.fps !== "number" ||
|
||||
!Number.isFinite(parsed.fps) ||
|
||||
parsed.fps <= 0
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid frames file "${filename}": expected non-empty frames[] and a positive fps`
|
||||
);
|
||||
}
|
||||
|
||||
const raw = parsed.frames as unknown[];
|
||||
const count = raw.length;
|
||||
const lengths = new Uint32Array(count);
|
||||
const bufs: Buffer[] = new Array(count);
|
||||
let total = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const b = Buffer.from(raw[i] as Uint8Array);
|
||||
bufs[i] = b;
|
||||
lengths[i] = b.length;
|
||||
total += b.length;
|
||||
}
|
||||
|
||||
const packed = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
packed.set(bufs[i], off);
|
||||
off += lengths[i];
|
||||
}
|
||||
|
||||
return { fps: parsed.fps, lengths, packed };
|
||||
}
|
||||
|
||||
const { filename } = workerData as { filename: string };
|
||||
const { fps, lengths, packed } = loadPacked(filename);
|
||||
parentPort!.postMessage(
|
||||
{ fps, lengths: lengths.buffer, packed: packed.buffer },
|
||||
[lengths.buffer, packed.buffer] as never
|
||||
);
|
||||
+73
-8
@@ -1,31 +1,96 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { frameToAscii } from "./frames";
|
||||
import { frameToAscii, resolveCharset, CHARSET_PRESETS } from "./frames";
|
||||
|
||||
describe("frameToAscii", () => {
|
||||
test("returns empty string for an empty buffer", () => {
|
||||
expect(frameToAscii(Buffer.from([]), 40)).toBe("");
|
||||
expect(frameToAscii(Buffer.from([]), { brightnessThreshold: 40 })).toBe(
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
test("maps a zero-brightness pixel to the first (blankest) character", () => {
|
||||
expect(frameToAscii(Buffer.from([0]), 40)).toBe(" ");
|
||||
expect(
|
||||
frameToAscii(Buffer.from([0]), { brightnessThreshold: 40 })
|
||||
).toBe(" ");
|
||||
});
|
||||
|
||||
test("maps a below-threshold pixel to the first character", () => {
|
||||
// brightness = floor((50/255)*100) = 19, below threshold 40
|
||||
expect(frameToAscii(Buffer.from([50]), 40)).toBe(" ");
|
||||
expect(
|
||||
frameToAscii(Buffer.from([50]), { brightnessThreshold: 40 })
|
||||
).toBe(" ");
|
||||
});
|
||||
|
||||
test("maps a mid-range pixel to a mid-range character", () => {
|
||||
// brightness = floor((128/255)*100) = 50
|
||||
expect(frameToAscii(Buffer.from([128]), 40)).toBe("n");
|
||||
expect(
|
||||
frameToAscii(Buffer.from([128]), { brightnessThreshold: 40 })
|
||||
).toBe("n");
|
||||
});
|
||||
|
||||
test("maps a max-brightness pixel to the last (densest) character", () => {
|
||||
// brightness = floor((255/255)*100) = 100, must clamp to last index
|
||||
expect(frameToAscii(Buffer.from([255]), 40)).toBe("$");
|
||||
expect(
|
||||
frameToAscii(Buffer.from([255]), { brightnessThreshold: 40 })
|
||||
).toBe("$");
|
||||
});
|
||||
|
||||
test("maps multiple pixels in sequence, preserving order", () => {
|
||||
expect(frameToAscii(Buffer.from([0, 128, 255]), 40)).toBe(" n$");
|
||||
expect(
|
||||
frameToAscii(Buffer.from([0, 128, 255]), {
|
||||
brightnessThreshold: 40,
|
||||
})
|
||||
).toBe(" n$");
|
||||
});
|
||||
|
||||
test("uses a named charset preset", () => {
|
||||
// standard ramp " .:-=+*#%@": max brightness -> last char "@"
|
||||
expect(
|
||||
frameToAscii(Buffer.from([255]), {
|
||||
brightnessThreshold: 40,
|
||||
charset: "standard",
|
||||
})
|
||||
).toBe("@");
|
||||
});
|
||||
|
||||
test("accepts a custom literal ramp", () => {
|
||||
expect(
|
||||
frameToAscii(Buffer.from([0, 255]), {
|
||||
brightnessThreshold: 0,
|
||||
charset: "AB",
|
||||
})
|
||||
).toBe("AB");
|
||||
});
|
||||
|
||||
test("invert reverses the ramp", () => {
|
||||
expect(
|
||||
frameToAscii(Buffer.from([255]), {
|
||||
brightnessThreshold: 40,
|
||||
charset: "AB",
|
||||
invert: true,
|
||||
})
|
||||
).toBe("A");
|
||||
});
|
||||
|
||||
test("supports multi-byte (Unicode) ramps", () => {
|
||||
expect(
|
||||
frameToAscii(Buffer.from([255]), {
|
||||
brightnessThreshold: 40,
|
||||
charset: "blocks",
|
||||
})
|
||||
).toBe("█");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCharset", () => {
|
||||
test("returns the detailed preset by default", () => {
|
||||
expect(resolveCharset()).toBe(CHARSET_PRESETS.detailed);
|
||||
});
|
||||
|
||||
test("resolves preset names case-insensitively", () => {
|
||||
expect(resolveCharset("BLOCKS")).toBe(CHARSET_PRESETS.blocks);
|
||||
});
|
||||
|
||||
test("passes through a custom ramp", () => {
|
||||
expect(resolveCharset(" .#@")).toBe(" .#@");
|
||||
});
|
||||
});
|
||||
|
||||
+122
-17
@@ -1,16 +1,82 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { Worker } from "worker_threads";
|
||||
import sharp from "sharp";
|
||||
|
||||
const WORKER_PATH = path.join(__dirname, "frameLoader.worker.ts");
|
||||
|
||||
export interface FramesContainer {
|
||||
frames: Buffer[];
|
||||
fps: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const PIXEL_CHARS =
|
||||
" .'`^\",:;Il!i><~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$";
|
||||
export const CHARSET_PRESETS: Record<string, string> = {
|
||||
detailed:
|
||||
" .'`^\",:;Il!i><~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$",
|
||||
standard: " .:-=+*#%@",
|
||||
simple: " .:oO#@",
|
||||
blocks: " ░▒▓█",
|
||||
};
|
||||
|
||||
const DEFAULT_CHARSET = CHARSET_PRESETS.detailed;
|
||||
|
||||
export interface AsciiOptions {
|
||||
brightnessThreshold?: number;
|
||||
charset?: string;
|
||||
invert?: boolean;
|
||||
}
|
||||
|
||||
export function resolveCharset(charset?: string): string {
|
||||
if (!charset) return DEFAULT_CHARSET;
|
||||
return CHARSET_PRESETS[charset.toLowerCase()] ?? charset;
|
||||
}
|
||||
|
||||
export function loadFrames(filename: string): FramesContainer {
|
||||
return JSON.parse(fs.readFileSync(filename).toString());
|
||||
const parsed = JSON.parse(fs.readFileSync(filename).toString());
|
||||
|
||||
if (
|
||||
!parsed ||
|
||||
!Array.isArray(parsed.frames) ||
|
||||
parsed.frames.length === 0 ||
|
||||
typeof parsed.fps !== "number" ||
|
||||
!Number.isFinite(parsed.fps) ||
|
||||
parsed.fps <= 0
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid frames file "${filename}": expected non-empty frames[] and a positive fps`
|
||||
);
|
||||
}
|
||||
|
||||
return parsed as FramesContainer;
|
||||
}
|
||||
|
||||
export function loadFramesAsync(filename: string): Promise<FramesContainer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(WORKER_PATH, { workerData: { filename } });
|
||||
worker.once(
|
||||
"message",
|
||||
(msg: {
|
||||
fps: number;
|
||||
lengths: ArrayBuffer;
|
||||
packed: ArrayBuffer;
|
||||
}) => {
|
||||
const lengths = new Uint32Array(msg.lengths);
|
||||
const frames: Buffer[] = new Array(lengths.length);
|
||||
let off = 0;
|
||||
for (let i = 0; i < lengths.length; i++) {
|
||||
frames[i] = Buffer.from(msg.packed, off, lengths[i]);
|
||||
off += lengths[i];
|
||||
}
|
||||
resolve({ frames, fps: msg.fps });
|
||||
worker.terminate();
|
||||
}
|
||||
);
|
||||
worker.once("error", (err) => {
|
||||
worker.terminate();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function resizeFrame(
|
||||
@@ -22,24 +88,65 @@ export async function resizeFrame(
|
||||
return sharp(frame)
|
||||
.resize(width, height, {
|
||||
fit: keepAspectRatio ? "contain" : "fill",
|
||||
})
|
||||
.grayscale()
|
||||
.extend({
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 1 },
|
||||
})
|
||||
.grayscale()
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
export class FrameRenderer {
|
||||
private cache = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
private readonly frames: Buffer[],
|
||||
private readonly options: AsciiOptions,
|
||||
private readonly maxEntries = 4096
|
||||
) {}
|
||||
|
||||
async render(
|
||||
index: number,
|
||||
width: number,
|
||||
height: number,
|
||||
keepAspectRatio: boolean
|
||||
): Promise<string> {
|
||||
const key = `${index}:${width}x${height}:${keepAspectRatio ? 1 : 0}`;
|
||||
const cached = this.cache.get(key);
|
||||
if (cached !== undefined) {
|
||||
this.cache.delete(key);
|
||||
this.cache.set(key, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const source = Buffer.isBuffer(this.frames[index])
|
||||
? this.frames[index]
|
||||
: Buffer.from(this.frames[index] as unknown as Uint8Array);
|
||||
const resized = await resizeFrame(
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
keepAspectRatio
|
||||
);
|
||||
const ascii = frameToAscii(resized, this.options);
|
||||
|
||||
this.cache.set(key, ascii);
|
||||
if (this.cache.size > this.maxEntries) {
|
||||
const oldest = this.cache.keys().next().value;
|
||||
if (oldest !== undefined) this.cache.delete(oldest);
|
||||
}
|
||||
return ascii;
|
||||
}
|
||||
}
|
||||
|
||||
export function frameToAscii(
|
||||
pixels: Buffer,
|
||||
brightnessThreshold: number
|
||||
options: AsciiOptions = {}
|
||||
): string {
|
||||
const totalPixelColors = PIXEL_CHARS.length;
|
||||
const { brightnessThreshold = 40, charset, invert = false } = options;
|
||||
|
||||
const ramp = [...resolveCharset(charset)];
|
||||
const total = ramp.length;
|
||||
let result = "";
|
||||
|
||||
for (let i = 0; i < pixels.length; i++) {
|
||||
@@ -49,13 +156,11 @@ export function frameToAscii(
|
||||
if (brightness < brightnessThreshold) {
|
||||
index = 0;
|
||||
} else {
|
||||
index = Math.min(
|
||||
Math.floor((brightness / 100) * totalPixelColors),
|
||||
totalPixelColors - 1
|
||||
);
|
||||
index = Math.min(Math.floor((brightness / 100) * total), total - 1);
|
||||
}
|
||||
|
||||
result += PIXEL_CHARS[index];
|
||||
if (invert) index = total - 1 - index;
|
||||
result += ramp[index];
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
Reference in New Issue
Block a user