mirror of
https://github.com/YuzuZensai/TrollSSH.git
synced 2026-09-13 21:39:02 +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 { describe, test, expect } from "bun:test";
|
||||||
import { frameToAscii } from "./frames";
|
import { frameToAscii, resolveCharset, CHARSET_PRESETS } from "./frames";
|
||||||
|
|
||||||
describe("frameToAscii", () => {
|
describe("frameToAscii", () => {
|
||||||
test("returns empty string for an empty buffer", () => {
|
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", () => {
|
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", () => {
|
test("maps a below-threshold pixel to the first character", () => {
|
||||||
// brightness = floor((50/255)*100) = 19, below threshold 40
|
// 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", () => {
|
test("maps a mid-range pixel to a mid-range character", () => {
|
||||||
// brightness = floor((128/255)*100) = 50
|
// 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", () => {
|
test("maps a max-brightness pixel to the last (densest) character", () => {
|
||||||
// brightness = floor((255/255)*100) = 100, must clamp to last index
|
expect(
|
||||||
expect(frameToAscii(Buffer.from([255]), 40)).toBe("$");
|
frameToAscii(Buffer.from([255]), { brightnessThreshold: 40 })
|
||||||
|
).toBe("$");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("maps multiple pixels in sequence, preserving order", () => {
|
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 fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { Worker } from "worker_threads";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
const WORKER_PATH = path.join(__dirname, "frameLoader.worker.ts");
|
||||||
|
|
||||||
export interface FramesContainer {
|
export interface FramesContainer {
|
||||||
frames: Buffer[];
|
frames: Buffer[];
|
||||||
fps: number;
|
fps: number;
|
||||||
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PIXEL_CHARS =
|
export const CHARSET_PRESETS: Record<string, string> = {
|
||||||
" .'`^\",:;Il!i><~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$";
|
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 {
|
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(
|
export async function resizeFrame(
|
||||||
@@ -22,24 +88,65 @@ export async function resizeFrame(
|
|||||||
return sharp(frame)
|
return sharp(frame)
|
||||||
.resize(width, height, {
|
.resize(width, height, {
|
||||||
fit: keepAspectRatio ? "contain" : "fill",
|
fit: keepAspectRatio ? "contain" : "fill",
|
||||||
})
|
|
||||||
.grayscale()
|
|
||||||
.extend({
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
background: { r: 0, g: 0, b: 0, alpha: 1 },
|
background: { r: 0, g: 0, b: 0, alpha: 1 },
|
||||||
})
|
})
|
||||||
|
.grayscale()
|
||||||
|
.removeAlpha()
|
||||||
.raw()
|
.raw()
|
||||||
.toBuffer();
|
.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(
|
export function frameToAscii(
|
||||||
pixels: Buffer,
|
pixels: Buffer,
|
||||||
brightnessThreshold: number
|
options: AsciiOptions = {}
|
||||||
): string {
|
): string {
|
||||||
const totalPixelColors = PIXEL_CHARS.length;
|
const { brightnessThreshold = 40, charset, invert = false } = options;
|
||||||
|
|
||||||
|
const ramp = [...resolveCharset(charset)];
|
||||||
|
const total = ramp.length;
|
||||||
let result = "";
|
let result = "";
|
||||||
|
|
||||||
for (let i = 0; i < pixels.length; i++) {
|
for (let i = 0; i < pixels.length; i++) {
|
||||||
@@ -49,13 +156,11 @@ export function frameToAscii(
|
|||||||
if (brightness < brightnessThreshold) {
|
if (brightness < brightnessThreshold) {
|
||||||
index = 0;
|
index = 0;
|
||||||
} else {
|
} else {
|
||||||
index = Math.min(
|
index = Math.min(Math.floor((brightness / 100) * total), total - 1);
|
||||||
Math.floor((brightness / 100) * totalPixelColors),
|
|
||||||
totalPixelColors - 1
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result += PIXEL_CHARS[index];
|
if (invert) index = total - 1 - index;
|
||||||
|
result += ramp[index];
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
Reference in New Issue
Block a user