55 lines
2.6 KiB
TypeScript
55 lines
2.6 KiB
TypeScript
import { createRequire } from "node:module";
|
||
|
||
const { GIFEncoder } = createRequire(import.meta.url)("gifenc") as {
|
||
GIFEncoder(): {
|
||
writeFrame(pixels: Uint8Array, width: number, height: number, options: {
|
||
palette: number[][]; delay: number; repeat: number;
|
||
}): void;
|
||
finish(): void;
|
||
bytes(): Uint8Array;
|
||
};
|
||
};
|
||
|
||
const glyphs: Record<string, string> = {
|
||
"0": "111101101101111", "1": "010110010010111", "2": "111001111100111",
|
||
"3": "111001111001111", "4": "101101111001001", "5": "111100111001111",
|
||
"6": "111100111101111", "7": "111001001001001", "8": "111101111101111",
|
||
"9": "111101111001111", ":": "000010000010000", " ": "000000000000000",
|
||
D: "110101101101110", H: "101101111101101", M: "101111111101101", S: "111100111001111",
|
||
};
|
||
|
||
export function countdownLabel(deadline: number, now: number): string {
|
||
if (!Number.isFinite(deadline) || !Number.isFinite(now)) throw new Error("Invalid countdown time.");
|
||
const seconds = Math.max(0, Math.ceil((deadline - now) / 1000));
|
||
const days = Math.floor(seconds / 86400);
|
||
if (days > 999) throw new Error("Countdown cannot exceed 999 days.");
|
||
const pair = (value: number) => String(value).padStart(2, "0");
|
||
return `${String(days).padStart(3, "0")}D ${pair(Math.floor(seconds / 3600) % 24)}H ${pair(Math.floor(seconds / 60) % 60)}M ${pair(seconds % 60)}S`;
|
||
}
|
||
|
||
/** A bounded, non-looping animation. The caller injects its clock for deterministic rendering. */
|
||
export function renderCountdownGif(deadline: number, now: number, frames = 60): Uint8Array {
|
||
if (!Number.isInteger(frames) || frames < 1 || frames > 60) throw new Error("Countdown requires 1–60 frames.");
|
||
countdownLabel(deadline, now);
|
||
const width = 320, height = 64, scale = 4;
|
||
const encoder = GIFEncoder();
|
||
const count = Math.min(frames, Math.max(1, Math.ceil((deadline - now) / 1000) + 1));
|
||
for (let frame = 0; frame < count; frame++) {
|
||
const label = countdownLabel(deadline, now + frame * 1000);
|
||
const pixels = new Uint8Array(width * height);
|
||
const left = Math.floor((width - label.length * 4 * scale) / 2);
|
||
for (let character = 0; character < label.length; character++) {
|
||
const glyph = glyphs[label[character]!]!;
|
||
for (let row = 0; row < 5; row++) for (let col = 0; col < 3; col++) {
|
||
if (glyph[row * 3 + col] !== "1") continue;
|
||
for (let y = 0; y < scale; y++) for (let x = 0; x < scale; x++) {
|
||
pixels[(22 + row * scale + y) * width + left + character * 4 * scale + col * scale + x] = 1;
|
||
}
|
||
}
|
||
}
|
||
encoder.writeFrame(pixels, width, height, { palette: [[17, 24, 39], [255, 255, 255]], delay: 1000, repeat: -1 });
|
||
}
|
||
encoder.finish();
|
||
return encoder.bytes();
|
||
}
|