feat: add charset presets, invert option, and cached ASCII rendering

This commit is contained in:
2026-07-13 17:00:21 +07:00
parent b705b02084
commit c6ac79dd3c
3 changed files with 246 additions and 25 deletions
+51
View File
@@ -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
View File
@@ -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
View File
@@ -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;