diff --git a/src/frameLoader.worker.ts b/src/frameLoader.worker.ts new file mode 100644 index 0000000..a3014a5 --- /dev/null +++ b/src/frameLoader.worker.ts @@ -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 +); diff --git a/src/frames.test.ts b/src/frames.test.ts index d82e5d6..83a790b 100644 --- a/src/frames.test.ts +++ b/src/frames.test.ts @@ -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(" .#@"); }); }); diff --git a/src/frames.ts b/src/frames.ts index 9981c74..2fc5c86 100644 --- a/src/frames.ts +++ b/src/frames.ts @@ -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 = { + 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 { + 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(); + + constructor( + private readonly frames: Buffer[], + private readonly options: AsciiOptions, + private readonly maxEntries = 4096 + ) {} + + async render( + index: number, + width: number, + height: number, + keepAspectRatio: boolean + ): Promise { + 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;