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 { 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
View File
@@ -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;